merge: AgentProject membership — the junction is now load-bearing

Merges feat/agent-project-membership (task 166/152). An agent can finally join
many projects: membership routes, per-membership role and optional brain
override, chat resolving context from the membership, and CLI attach-agent /
detach-agent / get memberships. The legacy Agent.projectId fallback is kept for
this release as the schema comment promises.

Conflict resolved in main.ts: that branch added its RBAC case to
mapUrlToPermission inline, while this branch had extracted the function to
middleware/url-permission.ts so it could be tested. Kept the extraction and
ported the case into it — joining a team is `edit:agents:<name>`, not
`create:agents`, which is the authority to mint a whole agent.

Also fixes something the extraction makes possible. security.test.ts contained a
hand-copied reproduction of mapUrlToPermission, because main.ts self-invokes
main() and nothing in it was importable. A security test asserting against a
copy stops testing the system the moment the real one changes, and this copy had
already drifted. It now imports the real function; all 18 assertions still pass,
against the code that actually runs.

Local: build clean, workspace 2550 passed, lint unchanged at 794 errors.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N4wNHWf7xSwnZCWpJcyv9p
This commit is contained in:
Michal
2026-08-09 23:34:18 +01:00
169 changed files with 8677 additions and 87 deletions

View File

@@ -9,8 +9,10 @@
*
* Modes:
* mcpctl chat <agent> # REPL, new thread
* mcpctl chat <agent> --project <p> # same person, viewing project p
* mcpctl chat <agent> --thread <id> # REPL, resume thread
* mcpctl chat <agent> -m "hi" # one-shot, prints reply, no REPL
* mcpctl chat --project <p> # the project itself, no agent
*
* Slash-commands inside the REPL:
* /set KEY VALUE # adjust an override (temperature 0.2)
@@ -40,7 +42,7 @@ export function createChatCommand(deps: ChatCommandDeps): Command {
return new Command('chat')
.description('Open an interactive chat session with an agent or a project (REPL or one-shot).')
.argument('[agent]', 'Agent name (see `mcpctl get agents`). Omit and use --project to chat with a project.')
.option('-p, --project <name>', 'Chat scoped to a project: its prompts, tools, and (with --allow-secrets) secrets')
.option('-p, --project <name>', 'With <agent>: view that project (agent must be a member). Alone: chat with the project itself')
.option('--allow-secrets', 'Project chat: let the model read secret values via the get_secret tool (needs view:secrets)')
.option('-m, --message <text>', 'One-shot: send a single message and exit (no REPL)')
.option('--thread <id>', 'Resume an existing thread')
@@ -77,18 +79,30 @@ interface ChatSubject {
path: string;
/** Project chat only: opt into the gated get_secret tool. */
allowSecrets: boolean;
/**
* Agent chat only: the project this turn is viewed through. The same person,
* looking at one of the teams they joined — mcpd requires an AgentProject
* membership and 404s with a join hint when there isn't one.
*/
project?: string;
}
function resolveSubject(agent: string | undefined, opts: ChatOpts): ChatSubject {
if (opts.project !== undefined && agent !== undefined) {
throw new Error('Pass either an <agent> or --project <name>, not both.');
if (agent !== undefined) {
if (opts.allowSecrets === true) {
throw new Error('--allow-secrets is project-chat only (drop the <agent> argument).');
}
return {
kind: 'agent',
name: agent,
path: `agents/${encodeURIComponent(agent)}`,
allowSecrets: false,
...(opts.project !== undefined ? { project: opts.project } : {}),
};
}
if (opts.project !== undefined) {
return { kind: 'project', name: opts.project, path: `projects/${encodeURIComponent(opts.project)}`, allowSecrets: opts.allowSecrets === true };
}
if (agent !== undefined) {
return { kind: 'agent', name: agent, path: `agents/${encodeURIComponent(agent)}`, allowSecrets: false };
}
throw new Error('Specify an <agent> name or --project <name>. See `mcpctl chat --help`.');
}
@@ -102,6 +116,10 @@ function chatBody(subject: ChatSubject, message: string, threadId: string | unde
if (subject.kind === 'project') {
delete o.personality;
if (subject.allowSecrets) o.allowSecrets = true;
} else if (subject.project !== undefined) {
// Agent chat viewed through one of the agent's projects. The endpoint is
// still the agent's — only the context layer changes.
o.project = subject.project;
}
const body: Record<string, unknown> = { message, ...o };
if (threadId !== undefined) body.threadId = threadId;
@@ -372,6 +390,10 @@ async function handleSlash(
let projectName: string | null;
if (subject.kind === 'project') {
projectName = subject.name;
} else if (subject.project !== undefined) {
// --project is the lens for this session; it beats the agent's
// legacy pin, exactly as it does server-side.
projectName = subject.project;
} else {
const a = await deps.client.get<{ project: { name: string } | null }>(
`/api/v1/agents/${encodeURIComponent(subject.name)}`,
@@ -880,7 +902,10 @@ async function printChatHeader(
out(sep);
out(`Agent: ${info.name}${info.description !== '' ? ` — ${info.description}` : ''}`);
const tail = info.project !== null ? ` Project: ${info.project.name}` : '';
// --project is the lens for this session; without it the agent's legacy
// `project` pin (if any) is what mcpd will use.
const viewing = subject.project ?? info.project?.name;
const tail = viewing !== undefined ? ` Project: ${viewing}` : '';
out(`LLM: ${info.llm.name}${tail}`);
// Personality overlay: explicit --personality wins; otherwise agent's
@@ -903,7 +928,7 @@ async function printChatHeader(
out(`System append (--system-append):`);
out(indent(overrides.systemAppend));
}
if (info.project !== null) {
if (viewing !== undefined) {
out(`(project prompts auto-appended at chat time; /tools lists MCP servers)`);
}

View File

@@ -234,6 +234,25 @@ const agentColumns: Column<AgentRow>[] = [
{ header: 'ID', key: 'id' },
];
/** One AgentProject row — an agent's seat on a project team. */
interface MembershipRow {
id: string;
agent: { id: string; name: string };
project: { id: string; name: string };
role: string;
llm: { id: string; name: string } | null;
createdAt: string;
}
const membershipColumns: Column<MembershipRow>[] = [
{ header: 'AGENT', key: (r) => r.agent.name, width: 24 },
{ header: 'PROJECT', key: (r) => r.project.name, width: 20 },
{ header: 'ROLE', key: 'role', width: 12 },
// '-' means "no override" — the agent's own pinned Llm answers here.
{ header: 'BRAIN', key: (r) => r.llm?.name ?? '-', width: 24 },
{ header: 'JOINED', key: (r) => new Date(r.createdAt).toLocaleString(), width: 20 },
];
interface ThreadRow { id: string; title: string; lastTurnAt: string; createdAt: string }
const threadColumns: Column<ThreadRow>[] = [
{ header: 'ID', key: 'id' },
@@ -520,6 +539,8 @@ function getColumnsForResource(resource: string): Column<Record<string, unknown>
return agentColumns as unknown as Column<Record<string, unknown>>[];
case 'personalities':
return personalityColumns as unknown as Column<Record<string, unknown>>[];
case 'memberships':
return membershipColumns as unknown as Column<Record<string, unknown>>[];
case 'threads':
return threadColumns as unknown as Column<Record<string, unknown>>[];
case 'inference-tasks':

View File

@@ -46,6 +46,53 @@ export function createDetachServerCommand(deps: ProjectOpsDeps): Command {
});
}
/**
* v1 persona roles. Mirrors MEMBERSHIP_ROLES in
* src/mcpd/src/validation/agent-project.schema.ts — mcpd is the authority, this
* list only exists so `--help` names the options and a typo fails locally.
*/
const MEMBERSHIP_ROLES = ['developer', 'qa', 'security', 'sre'] as const;
export function createAttachAgentCommand(deps: ProjectOpsDeps): Command {
const { client, log } = deps;
return new Command('attach-agent')
.description('Add an agent to a project team (requires --project)')
.argument('<agent-name>', 'Agent name to add')
.option('--role <role>', `Persona on this team: ${MEMBERSHIP_ROLES.join('|')}`)
.option('--llm <name>', 'Per-membership brain override (wins over the agent\'s pinned Llm in this project)')
.action(async (agentName: string, opts: { role?: string; llm?: string }) => {
const projectName = requireProject(deps);
if (opts.role !== undefined && !MEMBERSHIP_ROLES.includes(opts.role as typeof MEMBERSHIP_ROLES[number])) {
throw new Error(`--role must be one of: ${MEMBERSHIP_ROLES.join(', ')}`);
}
const membership = await client.post<{ role: string; llm: { name: string } | null }>(
`/api/v1/agents/${encodeURIComponent(agentName)}/projects/${encodeURIComponent(projectName)}`,
{
...(opts.role !== undefined ? { role: opts.role } : {}),
...(opts.llm !== undefined ? { llm: opts.llm } : {}),
},
);
const brain = membership.llm !== null ? `, brain ${membership.llm.name}` : '';
log(`agent '${agentName}' joined project '${projectName}' as ${membership.role}${brain}`);
});
}
export function createDetachAgentCommand(deps: ProjectOpsDeps): Command {
const { client, log } = deps;
return new Command('detach-agent')
.description('Remove an agent from a project team (requires --project)')
.argument('<agent-name>', 'Agent name to remove')
.action(async (agentName: string) => {
const projectName = requireProject(deps);
await client.delete(
`/api/v1/agents/${encodeURIComponent(agentName)}/projects/${encodeURIComponent(projectName)}`,
);
log(`agent '${agentName}' left project '${projectName}'`);
});
}
export function createApproveCommand(deps: ProjectOpsDeps): Command {
const { client, log } = deps;

View File

@@ -69,6 +69,11 @@ export const RESOURCE_ALIASES: Record<string, string> = {
agents: 'agents',
personality: 'personalities',
personalities: 'personalities',
// Agent ↔ Project team membership (the AgentProject junction). Read through
// one side or the other: `--project <p>` for the roster, or an agent name.
membership: 'memberships',
memberships: 'memberships',
team: 'memberships',
thread: 'threads',
threads: 'threads',
// v5 shipped the durable inference queue under the short `task`/`tasks`

View File

@@ -15,7 +15,13 @@ import { createCreateCommand } from './commands/create.js';
import { createEditCommand } from './commands/edit.js';
import { createBackupCommand } from './commands/backup.js';
import { createLoginCommand, createLogoutCommand } from './commands/auth.js';
import { createAttachServerCommand, createDetachServerCommand, createApproveCommand } from './commands/project-ops.js';
import {
createAttachServerCommand,
createDetachServerCommand,
createAttachAgentCommand,
createDetachAgentCommand,
createApproveCommand,
} from './commands/project-ops.js';
import { createFavouritesCommand } from './commands/favourites.js';
import { createMcpCommand } from './commands/mcp.js';
import { createPatchCommand } from './commands/patch.js';
@@ -135,6 +141,25 @@ export function createProgram(): Command {
return client.get<unknown[]>(`/api/v1/${resource}?project=${encodeURIComponent(projectName)}`);
}
// `get agents --project X` — the team: members plus any legacy-pinned
// agents. Same shape as the unscoped list, so the columns still line up.
if (nameOrId === undefined && resource === 'agents' && projectName !== undefined && projectName !== '') {
return client.get<unknown[]>(`/api/v1/projects/${encodeURIComponent(projectName)}/agents`);
}
// Memberships are a junction, not a resource with its own name — they are
// always read through one side. `--project` gives the roster with roles;
// otherwise name the agent (`get memberships <agent>`).
if (resource === 'memberships') {
if (projectName !== undefined && projectName !== '') {
return client.get<unknown[]>(`/api/v1/projects/${encodeURIComponent(projectName)}/memberships`);
}
if (nameOrId === undefined) {
throw new Error('memberships are scoped — pass --project <name> or an agent name (`mcpctl get memberships <agent>`)');
}
return client.get<unknown[]>(`/api/v1/agents/${encodeURIComponent(nameOrId)}/projects`);
}
// --project scoping for mcptokens
if (!nameOrId && resource === 'mcptokens' && projectName) {
return client.get<unknown[]>(`/api/v1/mcptokens?projectName=${encodeURIComponent(projectName)}`);
@@ -306,6 +331,9 @@ export function createProgram(): Command {
};
program.addCommand(createAttachServerCommand(projectOpsDeps), { hidden: true });
program.addCommand(createDetachServerCommand(projectOpsDeps), { hidden: true });
// Agentic teams: agents join projects the same way servers attach to them.
program.addCommand(createAttachAgentCommand(projectOpsDeps), { hidden: true });
program.addCommand(createDetachAgentCommand(projectOpsDeps), { hidden: true });
program.addCommand(createApproveCommand(projectOpsDeps));
program.addCommand(createFavouritesCommand(projectOpsDeps));
// PR-4: reviewer queue for proposed prompts + skills.

View File

@@ -0,0 +1,121 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import type { ApiClient } from '../../src/api-client.js';
import { createGetCommand } from '../../src/commands/get.js';
import {
createAttachAgentCommand,
createDetachAgentCommand,
} from '../../src/commands/project-ops.js';
function mockClient(overrides: Partial<ApiClient> = {}): ApiClient {
return {
get: vi.fn(async () => []),
post: vi.fn(async () => ({ role: 'developer', llm: null })),
put: vi.fn(async () => ({})),
delete: vi.fn(async () => {}),
...overrides,
} as unknown as ApiClient;
}
describe('mcpctl attach-agent / detach-agent', () => {
let client: ApiClient;
let output: string[];
const log = (...args: string[]) => output.push(args.join(' '));
beforeEach(() => {
client = mockClient();
output = [];
});
it('joins an agent to the --project team', async () => {
const cmd = createAttachAgentCommand({ client, log, getProject: () => 'sre' });
await cmd.parseAsync(['alice'], { from: 'user' });
expect(client.post).toHaveBeenCalledWith('/api/v1/agents/alice/projects/sre', {});
expect(output.join('\n')).toContain("agent 'alice' joined project 'sre' as developer");
});
it('passes --role and --llm through', async () => {
client = mockClient({
post: vi.fn(async () => ({ role: 'qa', llm: { id: 'llm-1', name: 'qwen3-thinking' } })) as ApiClient['post'],
});
const cmd = createAttachAgentCommand({ client, log, getProject: () => 'sre' });
await cmd.parseAsync(['alice', '--role', 'qa', '--llm', 'qwen3-thinking'], { from: 'user' });
expect(client.post).toHaveBeenCalledWith('/api/v1/agents/alice/projects/sre', {
role: 'qa',
llm: 'qwen3-thinking',
});
expect(output.join('\n')).toContain('brain qwen3-thinking');
});
it('rejects a role outside the v1 set before hitting the network', async () => {
const cmd = createAttachAgentCommand({ client, log, getProject: () => 'sre' });
await expect(
cmd.parseAsync(['alice', '--role', 'chief-vibes-officer'], { from: 'user' }),
).rejects.toThrow(/--role must be one of/);
expect(client.post).not.toHaveBeenCalled();
});
it('requires --project', async () => {
const cmd = createAttachAgentCommand({ client, log, getProject: () => undefined });
await expect(cmd.parseAsync(['alice'], { from: 'user' })).rejects.toThrow(/--project required/);
});
it('url-encodes names', async () => {
const cmd = createAttachAgentCommand({ client, log, getProject: () => 'my team' });
await cmd.parseAsync(['a/b'], { from: 'user' });
expect(client.post).toHaveBeenCalledWith('/api/v1/agents/a%2Fb/projects/my%20team', {});
});
it('detach-agent deletes the membership', async () => {
const cmd = createDetachAgentCommand({ client, log, getProject: () => 'sre' });
await cmd.parseAsync(['alice'], { from: 'user' });
expect(client.delete).toHaveBeenCalledWith('/api/v1/agents/alice/projects/sre');
expect(output.join('\n')).toContain("agent 'alice' left project 'sre'");
});
});
describe('mcpctl get memberships', () => {
it('renders the roster with role and brain columns', async () => {
const out: string[] = [];
const cmd = createGetCommand({
fetchResource: vi.fn(async () => [
{
id: 'ap-1',
agent: { id: 'agent-alice', name: 'alice' },
project: { id: 'proj-sre', name: 'sre' },
role: 'sre',
llm: { id: 'llm-1', name: 'qwen3-thinking' },
createdAt: '2026-01-01T00:00:00.000Z',
},
{
id: 'ap-2',
agent: { id: 'agent-bob', name: 'bob' },
project: { id: 'proj-sre', name: 'sre' },
role: 'qa',
llm: null,
createdAt: '2026-01-01T00:00:00.000Z',
},
]),
log: (...args: string[]) => out.push(args.join(' ')),
});
await cmd.parseAsync(['node', 'test', 'memberships', '--project', 'sre']);
const text = out.join('\n');
expect(text).toContain('ROLE');
expect(text).toContain('BRAIN');
expect(text).toContain('qwen3-thinking');
// No override reads as '-', not as an empty cell.
expect(text).toMatch(/bob\s+sre\s+qa\s+-/);
});
it('accepts the `team` alias', async () => {
const fetchResource = vi.fn(async () => []);
const out: string[] = [];
const cmd = createGetCommand({ fetchResource, log: (...a: string[]) => out.push(a.join(' ')) });
await cmd.parseAsync(['node', 'test', 'team', '--project', 'sre']);
expect(fetchResource).toHaveBeenCalledWith('memberships', undefined, { project: 'sre' });
});
});

View File

@@ -36,8 +36,10 @@ import { LlmService } from './services/llm.service.js';
import { InferenceTaskRepository } from './repositories/inference-task.repository.js';
import { InferenceTaskService } from './services/inference-task.service.js';
import { AgentRepository } from './repositories/agent.repository.js';
import { AgentProjectRepository } from './repositories/agent-project.repository.js';
import { ChatRepository } from './repositories/chat.repository.js';
import { AgentService } from './services/agent.service.js';
import { AgentProjectService } from './services/agent-project.service.js';
import { ChatService } from './services/chat.service.js';
import { ChatToolDispatcherImpl } from './services/chat-tool-dispatcher.js';
import { LlmAdapterRegistry } from './services/llm/dispatcher.js';
@@ -47,6 +49,7 @@ import { registerVirtualLlmRoutes } from './routes/virtual-llms.js';
import { registerInferenceTaskRoutes } from './routes/inference-tasks.js';
import { VirtualLlmService } from './services/virtual-llm.service.js';
import { registerAgentRoutes } from './routes/agents.js';
import { registerAgentProjectRoutes } from './routes/agent-projects.js';
import { registerAgentChatRoutes } from './routes/agent-chat.js';
import { registerProjectChatRoutes } from './routes/project-chat.js';
import { PromptRepository } from './repositories/prompt.repository.js';
@@ -408,7 +411,16 @@ async function main(): Promise<void> {
);
const personalityRepo = new PersonalityRepository(prisma);
const personalityService = new PersonalityService(personalityRepo, agentRepo, promptRepo);
const agentService = new AgentService(agentRepo, llmService, projectService, personalityRepo);
// Agentic teams: an agent is a person who joins projects. The junction repo
// goes into AgentService so both the project roster and the chat path read
// memberships, not just the legacy `Agent.projectId` pin.
const agentProjectRepo = new AgentProjectRepository(prisma);
const agentService = new AgentService(
agentRepo, llmService, projectService, personalityRepo, agentProjectRepo,
);
const agentProjectService = new AgentProjectService(
agentProjectRepo, agentService, projectService, llmService,
);
// v5: durable inference task queue. VirtualLlmService persists infer
// tasks here; the result-route updates them; an in-process emitter
// wakes blocked HTTP handlers when results land.
@@ -547,6 +559,7 @@ async function main(): Promise<void> {
registerSecretMigrateRoutes(app, secretMigrateService);
registerLlmRoutes(app, llmService);
registerAgentRoutes(app, agentService);
registerAgentProjectRoutes(app, agentProjectService);
registerPersonalityRoutes(app, personalityService);
// ChatService needs an `app.log`-aware tool dispatcher.
const chatToolDispatcher = new ChatToolDispatcherImpl({

View File

@@ -109,6 +109,22 @@ export function mapUrlToPermission(method: string, url: string): PermissionCheck
return { kind: 'resource', resource: 'epictasks', action: 'edit', resourceName: edgeMatch[1] };
}
// /api/v1/agents/:name/projects[/:project] — team membership.
// Reading the roster is `view:agents:<name>`; joining/leaving is
// `edit:agents:<name>`, mirroring project↔server attach/detach (which is
// `edit:projects`, not `create`). Without this the default mapping would
// grade a join as `create:agents` — the permission for minting a whole new
// agent, which is far too broad for "add this person to a team".
const membershipAgentName = url.match(/^\/api\/v1\/agents\/([^/?]+)\/projects/)?.[1];
if (membershipAgentName !== undefined) {
return {
kind: 'resource',
resource: 'agents',
action: method === 'GET' || method === 'HEAD' ? 'view' : 'edit',
resourceName: membershipAgentName,
};
}
// /api/v1/threads/:id/messages → `view:agents` (we don't carry the agent
// name in the URL; the service-level owner check enforces fine-grained
// access on top).

View File

@@ -0,0 +1,81 @@
import type { PrismaClient, AgentProject } from '@prisma/client';
/**
* AgentProject repository — the junction that makes an agent a *person who
* joins projects* rather than a thing owned by one.
*
* The row carries the two things that vary per project: the persona `role`
* (TEXT, validated in the service — production runs `prisma db push`, so a
* CHECK constraint here would never exist in prod) and an optional `llmId`
* brain override.
*/
export interface AgentProjectCreateInput {
agentId: string;
projectId: string;
role?: string;
llmId?: string | null;
}
export interface AgentProjectUpdateInput {
role?: string;
llmId?: string | null;
}
export interface IAgentProjectRepository {
findByAgent(agentId: string): Promise<AgentProject[]>;
findByProject(projectId: string): Promise<AgentProject[]>;
find(agentId: string, projectId: string): Promise<AgentProject | null>;
create(data: AgentProjectCreateInput): Promise<AgentProject>;
update(id: string, data: AgentProjectUpdateInput): Promise<AgentProject>;
delete(id: string): Promise<void>;
}
export class AgentProjectRepository implements IAgentProjectRepository {
constructor(private readonly prisma: PrismaClient) {}
async findByAgent(agentId: string): Promise<AgentProject[]> {
return this.prisma.agentProject.findMany({
where: { agentId },
orderBy: { createdAt: 'asc' },
});
}
async findByProject(projectId: string): Promise<AgentProject[]> {
return this.prisma.agentProject.findMany({
where: { projectId },
orderBy: { createdAt: 'asc' },
});
}
async find(agentId: string, projectId: string): Promise<AgentProject | null> {
return this.prisma.agentProject.findUnique({
where: { agentId_projectId: { agentId, projectId } },
});
}
async create(data: AgentProjectCreateInput): Promise<AgentProject> {
return this.prisma.agentProject.create({
data: {
agentId: data.agentId,
projectId: data.projectId,
...(data.role !== undefined ? { role: data.role } : {}),
...(data.llmId !== undefined ? { llmId: data.llmId } : {}),
},
});
}
async update(id: string, data: AgentProjectUpdateInput): Promise<AgentProject> {
return this.prisma.agentProject.update({
where: { id },
data: {
...(data.role !== undefined ? { role: data.role } : {}),
...(data.llmId !== undefined ? { llmId: data.llmId } : {}),
},
});
}
async delete(id: string): Promise<void> {
await this.prisma.agentProject.delete({ where: { id } });
}
}

View File

@@ -37,7 +37,7 @@ export function registerAgentChatRoutes(
}
const {
threadId, message, messages: messagesOverride, stream, personality,
threadId, message, messages: messagesOverride, stream, personality, project,
...paramsRest
} = parsed;
@@ -58,6 +58,7 @@ export function registerAgentChatRoutes(
? { messagesOverride: messagesOverride.map((m) => ({ role: m.role, content: m.content, ...(m.tool_call_id !== undefined ? { tool_call_id: m.tool_call_id } : {}) })) }
: {}),
...(personality !== undefined ? { personalityName: personality } : {}),
...(project !== undefined ? { projectName: project } : {}),
params: paramsRest,
};

View File

@@ -0,0 +1,95 @@
/**
* Agent ↔ Project membership HTTP surface.
*
* GET /api/v1/agents/:name/projects — projects this agent joined
* POST /api/v1/agents/:name/projects/:project — join (body: { role?, llm? })
* DELETE /api/v1/agents/:name/projects/:project — leave
* GET /api/v1/projects/:name/memberships — the roster, with roles
*
* The reverse *agent list* stays at `GET /api/v1/projects/:name/agents`
* (routes/agents.ts) because mcplocal's agents plugin consumes that shape;
* it now returns members as well as legacy-pinned agents. This route adds the
* membership detail (role + brain override) that an `Agent` row cannot carry.
*
* RBAC comes from the global hook in `main.ts:mapUrlToPermission`: reading the
* roster is `view:agents:<name>`, joining/leaving is `edit:agents:<name>` —
* mirroring project↔server attach/detach, which is `edit:projects`, not
* `create`.
*/
import type { FastifyInstance } from 'fastify';
import type { AgentProjectService } from '../services/agent-project.service.js';
import { NotFoundError } from '../services/mcp-server.service.js';
export function registerAgentProjectRoutes(
app: FastifyInstance,
service: AgentProjectService,
): void {
app.get<{ Params: { name: string } }>(
'/api/v1/agents/:name/projects',
async (request, reply) => {
try {
return await service.listForAgent(request.params.name);
} catch (err) {
if (err instanceof NotFoundError) {
reply.code(404);
return { error: err.message };
}
throw err;
}
},
);
app.post<{ Params: { name: string; project: string } }>(
'/api/v1/agents/:name/projects/:project',
async (request, reply) => {
try {
const membership = await service.join(
request.params.name,
request.params.project,
request.body ?? {},
);
reply.code(201);
return membership;
} catch (err) {
if (err instanceof NotFoundError) {
reply.code(404);
return { error: err.message };
}
throw err;
}
},
);
app.delete<{ Params: { name: string; project: string } }>(
'/api/v1/agents/:name/projects/:project',
async (request, reply) => {
try {
await service.leave(request.params.name, request.params.project);
reply.code(204);
return null;
} catch (err) {
if (err instanceof NotFoundError) {
reply.code(404);
return { error: err.message };
}
throw err;
}
},
);
app.get<{ Params: { name: string } }>(
'/api/v1/projects/:name/memberships',
async (request, reply) => {
try {
return await service.listForProject(request.params.name);
} catch (err) {
if (err instanceof NotFoundError) {
reply.code(404);
return { error: err.message };
}
throw err;
}
},
);
}

View File

@@ -0,0 +1,132 @@
/**
* AgentProjectService — team membership, the thing that turns an Agent from
* "a persona owned by one project" into "a person who joins zero, one, or many
* projects" (docs/agentic-teams.md §4).
*
* The junction row is where the two per-project facts hang:
* - `role` — the persona this agent wears on this team (developer/qa/…).
* - `llmId` — an optional brain override, so the open brain-ownership
* question (§15 Q1) can be answered per membership instead of
* globally.
*
* Invariants are enforced here, not in the database: production provisions with
* `prisma db push`, so migration-only CHECK constraints never exist in prod.
*/
import type { AgentProject } from '@prisma/client';
import type { IAgentProjectRepository } from '../repositories/agent-project.repository.js';
import {
DEFAULT_MEMBERSHIP_ROLE,
JoinProjectSchema,
MEMBERSHIP_ROLES,
} from '../validation/agent-project.schema.js';
import type { AgentService } from './agent.service.js';
import type { LlmService } from './llm.service.js';
import type { ProjectService } from './project.service.js';
import { NotFoundError } from './mcp-server.service.js';
/** One membership, name-resolved so callers never handle raw CUIDs. */
export interface MembershipView {
id: string;
agent: { id: string; name: string };
project: { id: string; name: string };
role: string;
/** Per-membership brain override; null means "use the agent's pinned Llm". */
llm: { id: string; name: string } | null;
createdAt: Date;
}
export class AgentProjectService {
constructor(
private readonly repo: IAgentProjectRepository,
private readonly agents: AgentService,
private readonly projects: ProjectService,
private readonly llms: LlmService,
) {}
/** Every project this agent has joined. */
async listForAgent(agentName: string): Promise<MembershipView[]> {
const agent = await this.agents.getByName(agentName);
const rows = await this.repo.findByAgent(agent.id);
return Promise.all(rows.map((r) => this.toView(r)));
}
/** Every agent that has joined this project — the team roster. */
async listForProject(projectName: string): Promise<MembershipView[]> {
const project = await this.projects.resolveAndGet(projectName);
const rows = await this.repo.findByProject(project.id);
return Promise.all(rows.map((r) => this.toView(r)));
}
/**
* Join a project. Idempotent by design: re-joining an existing membership
* updates the role/brain rather than 409-ing, so `apply`-style repeat calls
* converge instead of failing.
*/
async join(agentName: string, projectName: string, input: unknown): Promise<MembershipView> {
const data = JoinProjectSchema.parse(input ?? {});
const agent = await this.agents.getByName(agentName);
const project = await this.projects.resolveAndGet(projectName);
// `llm: null` clears the override; an absent `llm` leaves it untouched.
let llmId: string | null | undefined;
if (data.llm !== undefined) {
llmId = data.llm === null ? null : (await this.llms.getByName(data.llm)).id;
}
const existing = await this.repo.find(agent.id, project.id);
const row = existing !== null
? await this.repo.update(existing.id, {
...(data.role !== undefined ? { role: data.role } : {}),
...(llmId !== undefined ? { llmId } : {}),
})
: await this.repo.create({
agentId: agent.id,
projectId: project.id,
role: data.role ?? DEFAULT_MEMBERSHIP_ROLE,
...(llmId !== undefined ? { llmId } : {}),
});
return this.toView(row);
}
/** Leave a project. 404s when the agent was never a member — a silent no-op
* here would hide a typo'd project name. */
async leave(agentName: string, projectName: string): Promise<void> {
const agent = await this.agents.getByName(agentName);
const project = await this.projects.resolveAndGet(projectName);
const existing = await this.repo.find(agent.id, project.id);
if (existing === null) {
throw new NotFoundError(
`Agent '${agent.name}' is not a member of project '${project.name}'`,
);
}
await this.repo.delete(existing.id);
}
/** The valid role set, for error messages and CLI help. */
static roles(): readonly string[] {
return MEMBERSHIP_ROLES;
}
private async toView(row: AgentProject): Promise<MembershipView> {
const agent = await this.agents.getById(row.agentId);
const project = await this.projects.getById(row.projectId);
let llm: { id: string; name: string } | null = null;
if (row.llmId !== null) {
// A brain deleted out from under a membership sets llmId to NULL
// (onDelete: SetNull), so this only tolerates a torn read.
const found = await this.llms.getById(row.llmId).catch(() => null);
if (found !== null) llm = { id: found.id, name: found.name };
}
return {
id: row.id,
agent: { id: agent.id, name: agent.name },
project: { id: project.id, name: project.name },
role: row.role,
llm,
createdAt: row.createdAt,
};
}
}

View File

@@ -9,6 +9,7 @@
* identifiers.
*/
import type { Agent } from '@prisma/client';
import type { IAgentProjectRepository } from '../repositories/agent-project.repository.js';
import type { IAgentRepository } from '../repositories/agent.repository.js';
import type { IPersonalityRepository } from '../repositories/personality.repository.js';
import type { LlmService } from './llm.service.js';
@@ -88,12 +89,30 @@ export interface VirtualAgentInput {
visibility?: 'public' | 'private';
}
/**
* One resolved AgentProject membership, in the shape the chat path needs:
* which project the agent is looking at, and which brain wins there.
*/
export interface ResolvedMembership {
projectId: string;
projectName: string;
role: string;
/** Per-membership brain override; null means "use the agent's pinned Llm". */
llmName: string | null;
}
export class AgentService {
constructor(
private readonly repo: IAgentRepository,
private readonly llms: LlmService,
private readonly projects: ProjectService,
private readonly personalities?: IPersonalityRepository,
/**
* AgentProject junction. Optional so the many existing test wirings still
* compile; without it an agent simply has no memberships and the legacy
* `Agent.projectId` pin is the only project link.
*/
private readonly memberships?: IAgentProjectRepository,
) {}
async list(viewer: AgentViewer | null = null): Promise<AgentView[]> {
@@ -102,13 +121,90 @@ export class AgentService {
return Promise.all(visible.map((r) => this.toView(r)));
}
/**
* Agents attached to a project — by AgentProject membership OR by the legacy
* `Agent.projectId` pin. The union is what keeps this correct during the
* transition release: a joined agent must show up in the project's roster
* (mcplocal's agents plugin publishes these as virtual MCP servers), and a
* pre-membership agent must not vanish from it.
*/
async listByProject(projectName: string, viewer: AgentViewer | null = null): Promise<AgentView[]> {
const project = await this.projects.resolveAndGet(projectName);
const rows = await this.repo.findByProjectId(project.id);
const visible = rows.filter((r) => isAgentVisibleTo(r, viewer));
const byId = new Map(rows.map((r) => [r.id, r]));
if (this.memberships !== undefined) {
for (const m of await this.memberships.findByProject(project.id)) {
if (byId.has(m.agentId)) continue;
const agent = await this.repo.findById(m.agentId);
if (agent !== null) byId.set(agent.id, agent);
}
}
const visible = [...byId.values()]
.filter((r) => isAgentVisibleTo(r, viewer))
.sort((a, b) => a.name.localeCompare(b.name));
return Promise.all(visible.map((r) => this.toView(r)));
}
/**
* Which project is this agent looking at, and with which brain?
*
* Two resolution modes, deliberately different:
*
* - `projectName` given (`mcpctl chat <agent> --project <p>`): the agent
* must actually be on that team. A membership wins; the legacy
* `Agent.projectId` pin naming the same project is accepted as the
* transition fallback (the schema comment promises the column keeps
* working this release). Anything else throws, because silently
* answering from a *different* project's context would be worse than an
* error.
* - `projectName` omitted (`mcpctl chat <agent>`): unchanged legacy
* behaviour — the pinned `Agent.projectId`, or no project at all.
*
* The brain override only applies on the membership path: a legacy pin has
* no membership row and therefore no override to read.
*/
async resolveMembership(
agentName: string,
projectName?: string,
): Promise<ResolvedMembership | null> {
const agent = await this.getByName(agentName);
if (projectName === undefined) {
if (agent.project === null) return null;
return {
projectId: agent.project.id,
projectName: agent.project.name,
role: '',
llmName: null,
};
}
const project = await this.projects.resolveAndGet(projectName);
const row = this.memberships !== undefined
? await this.memberships.find(agent.id, project.id)
: null;
if (row !== null) {
return {
projectId: project.id,
projectName: project.name,
role: row.role,
llmName: row.llmId !== null ? (await this.llms.getById(row.llmId)).name : null,
};
}
if (agent.project !== null && agent.project.id === project.id) {
return { projectId: project.id, projectName: project.name, role: '', llmName: null };
}
throw new NotFoundError(
`Agent '${agent.name}' is not a member of project '${project.name}'. ` +
`Join it with: mcpctl attach-agent ${agent.name} --project ${project.name}`,
);
}
async getById(id: string, viewer: AgentViewer | null = null): Promise<AgentView> {
const row = await this.repo.findById(id);
if (row === null) throw new NotFoundError(`Agent not found: ${id}`);

View File

@@ -137,6 +137,13 @@ export interface PoolCandidate {
export interface ChatRequestArgs {
agentName: string;
/**
* Which project the agent is looking at for this turn — the same person,
* viewing one of the teams they joined (docs/agentic-teams.md §4). Requires
* an AgentProject membership; see AgentService.resolveMembership. Omitted =
* the agent on its own (falling back to the legacy `Agent.projectId` pin).
*/
projectName?: string;
/**
* The end user's bearer token. Only used by the `mcpctl` toolset, which acts
* AS the caller — see services/mcpctl-tools.ts. Absent for every other agent.
@@ -693,7 +700,15 @@ export class ChatService {
private async prepareContext(args: ChatRequestArgs): Promise<PreparedChatContext> {
const agent = await this.agents.getByName(args.agentName);
const pinned = await this.llms.getByName(agent.llm.name);
// Membership resolution runs BEFORE the Llm lookup, because a
// per-membership brain override has to be able to *replace* the agent's
// pinned Llm (and therefore its whole failover pool), not patch the
// dispatch list afterwards. Throws when `--project` names a team this
// agent has not joined.
const membership = await this.agents.resolveMembership(args.agentName, args.projectName);
const pinned = await this.llms.getByName(membership?.llmName ?? agent.llm.name);
const poolCandidates = await this.resolveCandidatesWithFallbacks(pinned);
if (poolCandidates.length === 0) {
// Pool exists but every member is inactive (publishers offline,
@@ -707,10 +722,10 @@ export class ChatService {
}
const threadId = await this.resolveThreadId(args, agent.id);
const projectId = agent.project?.id ?? null;
const projectId = membership?.projectId ?? null;
// Project prompts (existing): only those whose projectId actually matches
// the agent's project — `findAll(projectId)` also returns globals which
// the project in view — `findAll(projectId)` also returns globals which
// we exclude here so they don't double-count if a future change adds an
// explicit "global" injection step.
const projectPrompts = projectId !== null

View File

@@ -0,0 +1,32 @@
/**
* AgentProject (team membership) validation.
*
* `role` is TEXT in the schema on purpose — adding a role later must not be a
* migration. The allowed set therefore lives here, in the service layer, which
* is also the only place it *can* live: production provisions with
* `prisma db push`, so a CHECK constraint written into a migration would never
* run there.
*/
import { z } from 'zod';
/** v1 persona roles. Extend this list, not the database. */
export const MEMBERSHIP_ROLES = ['developer', 'qa', 'security', 'sre'] as const;
export type MembershipRole = (typeof MEMBERSHIP_ROLES)[number];
export const DEFAULT_MEMBERSHIP_ROLE: MembershipRole = 'developer';
/**
* Body for `POST /api/v1/agents/:name/projects/:project`.
*
* `llm` names the per-membership brain override; `null` clears it so a
* re-join can drop back to the agent's own pinned Llm.
*/
export const JoinProjectSchema = z
.object({
role: z.enum(MEMBERSHIP_ROLES).optional(),
llm: z.string().min(1).nullable().optional(),
})
.strict();
export type JoinProjectInput = z.infer<typeof JoinProjectSchema>;

View File

@@ -120,6 +120,12 @@ export const AgentChatRequestSchema = AgentChatParamsSchema.merge(
* agent's `defaultPersonalityId` when omitted.
*/
personality: z.string().min(1).optional(),
/**
* Which of the agent's projects to view for this turn — the second lens in
* docs/agentic-teams.md §4. Requires an AgentProject membership; the chat
* path 404s when the agent has not joined it.
*/
project: z.string().min(1).optional(),
}),
).strict().refine((v) => v.message !== undefined || (v.messages?.length ?? 0) > 0, {
message: 'Either `message` or `messages` is required',

View File

@@ -0,0 +1,198 @@
/**
* The membership half of AgentService: which project an agent is looking at,
* and which brain answers there.
*
* These tests exist because the AgentProject junction shipped inert — the table
* was there but nothing read it, so an agent still had exactly one project. The
* assertions below are what "an agent joins many projects" actually means in
* behaviour rather than in schema.
*/
import type { Agent, AgentProject } from '@prisma/client';
import { describe, it, expect, vi } from 'vitest';
import type { IAgentProjectRepository } from '../src/repositories/agent-project.repository.js';
import type { IAgentRepository } from '../src/repositories/agent.repository.js';
import { AgentService } from '../src/services/agent.service.js';
import type { LlmService } from '../src/services/llm.service.js';
import type { ProjectService } from '../src/services/project.service.js';
const NOW = new Date('2026-01-01T00:00:00Z');
function makeAgent(overrides: Partial<Agent> = {}): Agent {
return {
id: 'agent-alice',
name: 'alice',
description: '',
systemPrompt: '',
llmId: 'llm-home',
projectId: null,
defaultPersonalityId: null,
proxyModelName: null,
toolset: 'project',
defaultParams: {} as Agent['defaultParams'],
extras: {} as Agent['extras'],
visibility: 'public',
kind: 'public',
providerSessionId: null,
status: 'active',
lastHeartbeatAt: null,
inactiveSince: null,
ownerId: 'owner-1',
version: 1,
createdAt: NOW,
updatedAt: NOW,
...overrides,
} as Agent;
}
function harness(agents: Agent[], memberships: Array<Partial<AgentProject>> = []) {
const rows = new Map(agents.map((a) => [a.id, a]));
const links: AgentProject[] = memberships.map((m, i) => ({
id: `ap-${String(i)}`,
agentId: m.agentId ?? 'agent-alice',
projectId: m.projectId ?? 'proj-sre',
role: m.role ?? 'developer',
llmId: m.llmId ?? null,
createdAt: NOW,
}) as AgentProject);
const repo = {
findAll: vi.fn(async () => [...rows.values()]),
findById: vi.fn(async (id: string) => rows.get(id) ?? null),
findByName: vi.fn(async (name: string) => [...rows.values()].find((r) => r.name === name) ?? null),
findByProjectId: vi.fn(async (projectId: string) =>
[...rows.values()].filter((r) => r.projectId === projectId)),
} as unknown as IAgentRepository;
const junction = {
findByAgent: vi.fn(async (agentId: string) => links.filter((l) => l.agentId === agentId)),
findByProject: vi.fn(async (projectId: string) => links.filter((l) => l.projectId === projectId)),
find: vi.fn(async (agentId: string, projectId: string) =>
links.find((l) => l.agentId === agentId && l.projectId === projectId) ?? null),
} as unknown as IAgentProjectRepository;
const llms = {
getById: vi.fn(async (id: string) => ({ id, name: id.replace(/^llm-/, '') })),
getByName: vi.fn(async (name: string) => ({ id: `llm-${name}`, name })),
} as unknown as LlmService;
const projects = {
getById: vi.fn(async (id: string) => ({ id, name: id.replace(/^proj-/, '') })),
resolveAndGet: vi.fn(async (name: string) => ({ id: `proj-${name}`, name })),
} as unknown as ProjectService;
return {
withMemberships: new AgentService(repo, llms, projects, undefined, junction),
// The same service without the junction wired — proves the transition
// fallback still works for a deployment that has not migrated.
legacyOnly: new AgentService(repo, llms, projects),
};
}
describe('AgentService.resolveMembership — no --project (the person on their own)', () => {
it('returns nothing for an agent with no project at all', async () => {
const { withMemberships } = harness([makeAgent()]);
expect(await withMemberships.resolveMembership('alice')).toBeNull();
});
it('falls back to the legacy Agent.projectId pin', async () => {
const { withMemberships } = harness([makeAgent({ projectId: 'proj-legacy' })]);
const m = await withMemberships.resolveMembership('alice');
expect(m?.projectName).toBe('legacy');
expect(m?.llmName).toBeNull();
});
});
describe('AgentService.resolveMembership — with --project (the person viewing a team)', () => {
it('resolves a joined project', async () => {
const { withMemberships } = harness([makeAgent()], [{ projectId: 'proj-sre', role: 'sre' }]);
const m = await withMemberships.resolveMembership('alice', 'sre');
expect(m).toMatchObject({ projectId: 'proj-sre', projectName: 'sre', role: 'sre', llmName: null });
});
it('picks the RIGHT membership when the agent is on several teams', async () => {
const { withMemberships } = harness(
[makeAgent()],
[
{ projectId: 'proj-sre', role: 'sre' },
{ projectId: 'proj-payments', role: 'security' },
],
);
expect((await withMemberships.resolveMembership('alice', 'payments'))?.role).toBe('security');
expect((await withMemberships.resolveMembership('alice', 'sre'))?.role).toBe('sre');
});
it('throws with a join hint when the agent is not on that team', async () => {
const { withMemberships } = harness([makeAgent()], [{ projectId: 'proj-sre' }]);
await expect(withMemberships.resolveMembership('alice', 'payments')).rejects.toThrow(
/is not a member of project 'payments'.*mcpctl attach-agent alice --project payments/s,
);
});
it('accepts the legacy pin as the transition fallback when it names the same project', async () => {
const { withMemberships } = harness([makeAgent({ projectId: 'proj-sre' })]);
const m = await withMemberships.resolveMembership('alice', 'sre');
expect(m?.projectId).toBe('proj-sre');
// No membership row means no override to read.
expect(m?.llmName).toBeNull();
});
it('still refuses a DIFFERENT project than the legacy pin — silently answering from the wrong context would be worse than an error', async () => {
const { withMemberships } = harness([makeAgent({ projectId: 'proj-sre' })]);
await expect(withMemberships.resolveMembership('alice', 'payments')).rejects.toThrow(/not a member/);
});
it('works without the junction wired at all (pre-migration deployment)', async () => {
const { legacyOnly } = harness([makeAgent({ projectId: 'proj-sre' })]);
expect((await legacyOnly.resolveMembership('alice', 'sre'))?.projectId).toBe('proj-sre');
await expect(legacyOnly.resolveMembership('alice', 'payments')).rejects.toThrow(/not a member/);
});
});
describe('AgentService.resolveMembership — the per-membership brain override', () => {
it('reports the override so chat can dispatch to it', async () => {
const { withMemberships } = harness(
[makeAgent({ llmId: 'llm-home' })],
[{ projectId: 'proj-sre', llmId: 'llm-sre-brain' }],
);
expect((await withMemberships.resolveMembership('alice', 'sre'))?.llmName).toBe('sre-brain');
});
it('is per-membership: the same agent keeps its own brain on another team', async () => {
const { withMemberships } = harness(
[makeAgent()],
[
{ projectId: 'proj-sre', llmId: 'llm-sre-brain' },
{ projectId: 'proj-payments', llmId: null },
],
);
expect((await withMemberships.resolveMembership('alice', 'sre'))?.llmName).toBe('sre-brain');
expect((await withMemberships.resolveMembership('alice', 'payments'))?.llmName).toBeNull();
});
});
describe('AgentService.listByProject — the roster', () => {
it('unions members with legacy-pinned agents, without duplicates', async () => {
const { withMemberships } = harness(
[
makeAgent({ id: 'agent-alice', name: 'alice', projectId: 'proj-sre' }), // legacy pin
makeAgent({ id: 'agent-bob', name: 'bob' }), // member only
makeAgent({ id: 'agent-carol', name: 'carol' }), // neither
],
[
{ agentId: 'agent-bob', projectId: 'proj-sre' },
{ agentId: 'agent-alice', projectId: 'proj-sre' }, // both pinned AND joined
],
);
const roster = await withMemberships.listByProject('sre');
expect(roster.map((a) => a.name)).toEqual(['alice', 'bob']);
});
it('returns only legacy-pinned agents when the junction is not wired', async () => {
const { legacyOnly } = harness(
[makeAgent({ id: 'agent-alice', name: 'alice', projectId: 'proj-sre' }), makeAgent({ id: 'agent-bob', name: 'bob' })],
[{ agentId: 'agent-bob', projectId: 'proj-sre' }],
);
expect((await legacyOnly.listByProject('sre')).map((a) => a.name)).toEqual(['alice']);
});
});

View File

@@ -0,0 +1,103 @@
import Fastify, { type FastifyInstance } from 'fastify';
import { describe, it, expect, vi, afterEach } from 'vitest';
import { errorHandler } from '../src/middleware/error-handler.js';
import { registerAgentProjectRoutes } from '../src/routes/agent-projects.js';
import type { AgentProjectService, MembershipView } from '../src/services/agent-project.service.js';
import { NotFoundError } from '../src/services/mcp-server.service.js';
const NOW = new Date('2026-01-01T00:00:00Z');
function view(overrides: Partial<MembershipView> = {}): MembershipView {
return {
id: 'ap-1',
agent: { id: 'agent-alice', name: 'alice' },
project: { id: 'proj-sre', name: 'sre' },
role: 'sre',
llm: null,
createdAt: NOW,
...overrides,
};
}
function build(service: Partial<Record<keyof AgentProjectService, unknown>>): FastifyInstance {
const app = Fastify();
app.setErrorHandler(errorHandler);
registerAgentProjectRoutes(app, service as unknown as AgentProjectService);
return app;
}
let app: FastifyInstance | undefined;
afterEach(async () => { await app?.close(); app = undefined; });
describe('agent ↔ project membership routes', () => {
it('GET /agents/:name/projects lists memberships', async () => {
app = build({ listForAgent: vi.fn(async () => [view(), view({ id: 'ap-2', project: { id: 'proj-pay', name: 'payments' } })]) });
const res = await app.inject({ method: 'GET', url: '/api/v1/agents/alice/projects' });
expect(res.statusCode).toBe(200);
expect(res.json()).toHaveLength(2);
});
it('POST /agents/:name/projects/:project joins and returns 201', async () => {
const join = vi.fn(async () => view({ role: 'qa' }));
app = build({ join });
const res = await app.inject({
method: 'POST',
url: '/api/v1/agents/alice/projects/sre',
payload: { role: 'qa' },
});
expect(res.statusCode).toBe(201);
expect(res.json().role).toBe('qa');
expect(join).toHaveBeenCalledWith('alice', 'sre', { role: 'qa' });
});
it('POST with no body still joins — role and brain are both optional', async () => {
const join = vi.fn(async () => view());
app = build({ join });
const res = await app.inject({ method: 'POST', url: '/api/v1/agents/alice/projects/sre' });
expect(res.statusCode).toBe(201);
expect(join).toHaveBeenCalledWith('alice', 'sre', {});
});
it('DELETE /agents/:name/projects/:project leaves and returns 204', async () => {
const leave = vi.fn(async () => undefined);
app = build({ leave });
const res = await app.inject({ method: 'DELETE', url: '/api/v1/agents/alice/projects/sre' });
expect(res.statusCode).toBe(204);
expect(leave).toHaveBeenCalledWith('alice', 'sre');
});
it('DELETE 404s when the agent was never a member', async () => {
app = build({
leave: vi.fn(async () => { throw new NotFoundError("Agent 'alice' is not a member of project 'sre'"); }),
});
const res = await app.inject({ method: 'DELETE', url: '/api/v1/agents/alice/projects/sre' });
expect(res.statusCode).toBe(404);
expect(res.json().error).toMatch(/not a member/);
});
it('404s on an unknown agent', async () => {
app = build({ listForAgent: vi.fn(async () => { throw new NotFoundError('Agent not found: ghost'); }) });
const res = await app.inject({ method: 'GET', url: '/api/v1/agents/ghost/projects' });
expect(res.statusCode).toBe(404);
});
it('GET /projects/:name/memberships is the reverse view, with roles', async () => {
app = build({
listForProject: vi.fn(async () => [
view({ agent: { id: 'agent-alice', name: 'alice' }, role: 'sre' }),
view({ id: 'ap-2', agent: { id: 'agent-bob', name: 'bob' }, role: 'qa' }),
]),
});
const res = await app.inject({ method: 'GET', url: '/api/v1/projects/sre/memberships' });
expect(res.statusCode).toBe(200);
expect(res.json().map((m: MembershipView) => `${m.agent.name}:${m.role}`)).toEqual(['alice:sre', 'bob:qa']);
});
it('url-decodes names before handing them to the service', async () => {
const join = vi.fn(async () => view());
app = build({ join });
await app.inject({ method: 'POST', url: '/api/v1/agents/alice/projects/my%2Dteam' });
expect(join).toHaveBeenCalledWith('alice', 'my-team', {});
});
});

View File

@@ -0,0 +1,180 @@
/**
* `mcpctl chat <agent> --project <p>` — the same person, viewing one of the
* teams they joined (docs/agentic-teams.md §4).
*
* The regression these lock down: the AgentProject junction existed for a whole
* release without the chat path reading it, so `--project` on an agent could
* only ever mean the one project pinned on the Agent row.
*/
import type { ChatMessage, ChatThread, Prompt } from '@prisma/client';
import { describe, it, expect, vi } from 'vitest';
import type { IChatRepository } from '../src/repositories/chat.repository.js';
import type { IPromptRepository } from '../src/repositories/prompt.repository.js';
import type { AgentService, ResolvedMembership } from '../src/services/agent.service.js';
import { ChatService, type ChatToolDispatcher } from '../src/services/chat.service.js';
import type { LlmAdapterRegistry } from '../src/services/llm/dispatcher.js';
import type { LlmAdapter, NonStreamingResult } from '../src/services/llm/types.js';
import type { LlmService } from '../src/services/llm.service.js';
import { NotFoundError } from '../src/services/mcp-server.service.js';
const NOW = new Date('2026-01-01T00:00:00Z');
function mockChatRepo(): IChatRepository {
const msgs: ChatMessage[] = [];
let n = 1;
return {
createThread: vi.fn(async ({ agentId, ownerId }) => ({
id: `thread-${String(n++)}`, agentId, projectId: null, ownerId,
title: '', lastTurnAt: NOW, createdAt: NOW, updatedAt: NOW,
} as ChatThread)),
findThread: vi.fn(async () => null),
listThreadsByAgent: vi.fn(async () => []),
listMessages: vi.fn(async () => []),
appendMessage: vi.fn(async (input: { threadId: string; role: string; content: string }) => {
const m = {
id: `msg-${String(n++)}`, threadId: input.threadId, turnIndex: msgs.length,
role: input.role, content: input.content, toolCalls: null, toolCallId: null,
status: 'complete', createdAt: NOW,
} as unknown as ChatMessage;
msgs.push(m);
return m;
}),
updateStatus: vi.fn(async () => ({}) as ChatMessage),
markPendingAsError: vi.fn(async () => 0),
touchThread: vi.fn(async () => undefined),
nextTurnIndex: vi.fn(async () => msgs.length),
} as unknown as IChatRepository;
}
function mockPromptRepo(rows: Prompt[] = []): IPromptRepository {
return {
findAll: vi.fn(async () => rows),
findByAgent: vi.fn(async () => []),
} as unknown as IPromptRepository;
}
/**
* The agent: pinned to `home-brain`, legacy-pinned to project `legacy`, and a
* member of `sre` (with its own brain) and `payments` (without one).
*/
const MEMBERSHIPS: Record<string, ResolvedMembership> = {
sre: { projectId: 'proj-sre', projectName: 'sre', role: 'sre', llmName: 'sre-brain' },
payments: { projectId: 'proj-payments', projectName: 'payments', role: 'security', llmName: null },
};
function mockAgents(): AgentService {
return {
getByName: vi.fn(async (name: string) => ({
id: `agent-${name}`, name, description: '', systemPrompt: 'You are alice.',
llm: { id: 'llm-home-brain', name: 'home-brain' },
project: { id: 'proj-legacy', name: 'legacy' },
defaultPersonality: null, proxyModelName: null, toolset: 'project',
defaultParams: {}, extras: {}, ownerId: 'owner-1', version: 1,
createdAt: NOW, updatedAt: NOW,
})),
resolveMembership: vi.fn(async (_name: string, projectName?: string) => {
if (projectName === undefined) {
return { projectId: 'proj-legacy', projectName: 'legacy', role: '', llmName: null };
}
const found = MEMBERSHIPS[projectName];
if (found === undefined) {
throw new NotFoundError(`Agent 'alice' is not a member of project '${projectName}'.`);
}
return found;
}),
} as unknown as AgentService;
}
function mockLlms(): LlmService {
const row = (name: string): Record<string, unknown> => ({
id: `llm-${name}`, name, type: 'openai', model: `${name}-model`,
url: '', tier: 'fast', description: '',
apiKeySecretId: null, apiKeySecretKey: null, extraConfig: {}, poolName: null,
kind: 'public', providerSessionId: null, status: 'active',
lastHeartbeatAt: null, inactiveSince: null,
version: 1, createdAt: NOW, updatedAt: NOW,
});
return {
getByName: vi.fn(async (name: string) => ({ ...row(name), apiKeyRef: null })),
findByPoolName: vi.fn(async (poolName: string) => [row(poolName)]),
resolveApiKey: vi.fn(async () => 'fake-key'),
} as unknown as LlmService;
}
function reply(content: string): NonStreamingResult {
return {
status: 200,
body: { id: 'c', object: 'chat.completion', choices: [{ index: 0, message: { role: 'assistant', content }, finish_reason: 'stop' }] },
};
}
function build() {
const listTools = vi.fn(async () => []);
const tools = { listTools, callTool: vi.fn(async () => ({})) } as unknown as ChatToolDispatcher;
const llms = mockLlms();
const promptRepo = mockPromptRepo();
const adapter: LlmAdapter = {
kind: 'scripted',
infer: vi.fn(async () => reply('ok')),
stream: async function*() { yield { data: '[DONE]', done: true }; },
} as unknown as LlmAdapter;
const service = new ChatService(
mockAgents(), llms, { get: () => adapter } as unknown as LlmAdapterRegistry,
mockChatRepo(), promptRepo, tools,
);
return { service, llms, listTools, promptRepo, adapter };
}
describe('agent chat scoped to a joined project', () => {
it('uses the MEMBERSHIP project for tools, not the legacy pin', async () => {
const { service, listTools } = build();
await service.chat({ agentName: 'alice', ownerId: 'u1', userMessage: 'hi', projectName: 'payments' });
expect(listTools).toHaveBeenCalledWith('proj-payments');
});
it('falls back to the legacy pin when no project is named', async () => {
const { service, listTools } = build();
await service.chat({ agentName: 'alice', ownerId: 'u1', userMessage: 'hi' });
expect(listTools).toHaveBeenCalledWith('proj-legacy');
});
it('the per-membership brain wins over the agent\'s pinned Llm', async () => {
const { service, llms } = build();
const res = await service.chat({ agentName: 'alice', ownerId: 'u1', userMessage: 'hi', projectName: 'sre' });
expect(llms.getByName).toHaveBeenCalledWith('sre-brain');
expect(llms.getByName).not.toHaveBeenCalledWith('home-brain');
// Reported back to the caller, so `mcpctl chat` can show which brain answered.
expect(res.llm).toBe('sre-brain');
});
it('keeps the agent\'s own brain on a membership with no override', async () => {
const { service, llms } = build();
const res = await service.chat({ agentName: 'alice', ownerId: 'u1', userMessage: 'hi', projectName: 'payments' });
expect(llms.getByName).toHaveBeenCalledWith('home-brain');
expect(res.llm).toBe('home-brain');
});
it('and on bare chat, with no project at all', async () => {
const { service, llms } = build();
await service.chat({ agentName: 'alice', ownerId: 'u1', userMessage: 'hi' });
expect(llms.getByName).toHaveBeenCalledWith('home-brain');
});
it('refuses a project the agent has not joined — before spending a token', async () => {
const { service, adapter } = build();
await expect(
service.chat({ agentName: 'alice', ownerId: 'u1', userMessage: 'hi', projectName: 'finance' }),
).rejects.toThrow(/not a member of project 'finance'/);
expect(adapter.infer).not.toHaveBeenCalled();
});
it('scopes the streaming path the same way', async () => {
const { service, listTools } = build();
const chunks = [];
for await (const c of service.chatStream({ agentName: 'alice', ownerId: 'u1', userMessage: 'hi', projectName: 'sre' })) {
chunks.push(c);
}
expect(listTools).toHaveBeenCalledWith('proj-sre');
});
});

View File

@@ -56,6 +56,7 @@ function mockChatRepo(): IChatRepository {
function mockAgents(): AgentService {
return {
resolveMembership: vi.fn(async () => null),
getByName: vi.fn(async (name: string) => ({
id: `agent-${name}`, name, description: '',
systemPrompt: 'You are a helpful agent.',

View File

@@ -97,6 +97,15 @@ function mockTools(impl: Partial<ChatToolDispatcher> = {}): ChatToolDispatcher {
function mockAgents(opts: { defaultPersonality?: { id: string; name: string } | null } = {}): AgentService {
return {
// Membership resolution with no `--project`: the legacy `Agent.projectId`
// pin, exactly what the real service returns for the same input.
resolveMembership: vi.fn(async (name: string, projectName?: string) => {
if (projectName !== undefined) {
return { projectId: `proj-${projectName}`, projectName, role: 'developer', llmName: null };
}
if (name === 'no-project') return null;
return { projectId: 'proj-1', projectName: 'mcpctl-dev', role: '', llmName: null };
}),
getByName: vi.fn(async (name: string) => ({
id: `agent-${name}`,
name,
@@ -801,6 +810,9 @@ describe('ChatService', () => {
});
// Agent with maxIterations=2 — only 2 tool-call rounds allowed before bail.
const agentsLowCap = {
resolveMembership: vi.fn(async () => ({
projectId: 'proj-1', projectName: 'mcpctl-dev', role: '', llmName: null,
})),
getByName: vi.fn(async () => ({
id: 'agent-low', name: 'low', description: '', systemPrompt: '',
llm: { id: 'llm-1', name: 'qwen3-thinking' },
@@ -821,6 +833,9 @@ describe('ChatService', () => {
// Hostile agent with maxIterations=1000000 — must clamp to 50, not iterate forever.
const agentsHostile = {
resolveMembership: vi.fn(async () => ({
projectId: 'proj-1', projectName: 'mcpctl-dev', role: '', llmName: null,
})),
getByName: vi.fn(async () => ({
id: 'agent-bad', name: 'bad', description: '', systemPrompt: '',
llm: { id: 'llm-1', name: 'qwen3-thinking' },
@@ -1106,6 +1121,8 @@ describe('ChatService — failover chain', () => {
}
function agentPinnedTo(llmName: string): AgentService {
return {
// No project, no membership — the failover chain is about the Llm pool.
resolveMembership: vi.fn(async () => null),
getByName: vi.fn(async (name: string) => ({
id: `agent-${name}`, name, description: '', systemPrompt: 'sys',
llm: { id: 'x', name: llmName }, project: null, defaultPersonality: null,

View File

@@ -19,71 +19,17 @@ import { AuditEventService } from '../src/services/audit-event.service.js';
import type { IAuditEventRepository } from '../src/repositories/interfaces.js';
import { errorHandler } from '../src/middleware/error-handler.js';
import { CreateMcpServerSchema } from '../src/validation/mcp-server.schema.js';
import { mapUrlToPermission } from '../src/middleware/url-permission.js';
// ─────────────────────────────────────────────────────────
// § 1 audit-events endpoint bypasses RBAC
// ─────────────────────────────────────────────────────────
/**
* Reproduce mapUrlToPermission from main.ts to test which URLs
* get RBAC checks and which are skipped.
*/
type PermissionCheck =
| { kind: 'resource'; resource: string; action: string; resourceName?: string }
| { kind: 'operation'; operation: string }
| { kind: 'skip' };
function mapUrlToPermission(method: string, url: string): PermissionCheck {
const match = url.match(/^\/api\/v1\/([a-z-]+)/);
if (!match) return { kind: 'skip' };
const segment = match[1] as string;
if (segment === 'backup') return { kind: 'operation', operation: 'backup' };
if (segment === 'restore') return { kind: 'operation', operation: 'restore' };
if (segment === 'audit-logs' && method === 'DELETE') return { kind: 'operation', operation: 'audit-purge' };
const resourceMap: Record<string, string | undefined> = {
'servers': 'servers',
'instances': 'instances',
'secrets': 'secrets',
'projects': 'projects',
'templates': 'templates',
'users': 'users',
'groups': 'groups',
'rbac': 'rbac',
'audit-logs': 'rbac',
'mcp': 'servers',
'prompts': 'prompts',
'promptrequests': 'promptrequests',
};
const resource = resourceMap[segment];
if (resource === undefined) return { kind: 'skip' };
let action: string;
switch (method) {
case 'GET':
case 'HEAD':
action = 'view';
break;
case 'POST':
action = 'create';
break;
case 'DELETE':
action = 'delete';
break;
default:
action = 'edit';
break;
}
const nameMatch = url.match(/^\/api\/v1\/[a-z-]+\/([^/?]+)/);
const resourceName = nameMatch?.[1];
const check: PermissionCheck = { kind: 'resource', resource, action };
if (resourceName !== undefined) (check as { resourceName: string }).resourceName = resourceName;
return check;
}
// Uses the REAL mapping, not a copy. This file previously reproduced
// mapUrlToPermission by hand because main.ts self-invokes main() and nothing
// in it was importable. A security test asserting against a hand-copied
// duplicate stops testing the system the moment the real one changes — and
// this copy had already drifted. It now lives in middleware/url-permission.ts.
describe('Security: RBAC coverage gaps in mapUrlToPermission', () => {
it('audit-events endpoint is NOT in resourceMap — bypasses RBAC', () => {

View File

@@ -0,0 +1,215 @@
import type { AgentProject } from '@prisma/client';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import type { IAgentProjectRepository } from '../../src/repositories/agent-project.repository.js';
import { AgentProjectService } from '../../src/services/agent-project.service.js';
import type { AgentService } from '../../src/services/agent.service.js';
import type { LlmService } from '../../src/services/llm.service.js';
import { NotFoundError } from '../../src/services/mcp-server.service.js';
import type { ProjectService } from '../../src/services/project.service.js';
const NOW = new Date('2026-01-01T00:00:00Z');
/**
* In-memory AgentProject junction. The unique (agentId, projectId) constraint
* is modelled here because the service's idempotent re-join depends on it —
* without it the "join twice" test would pass for the wrong reason.
*/
function makeHarness(seed: Array<Partial<AgentProject>> = []) {
let idCounter = 1;
let rows: AgentProject[] = seed.map((r) => ({
id: r.id ?? `ap-${String(idCounter++)}`,
agentId: r.agentId ?? 'agent-alice',
projectId: r.projectId ?? 'proj-sre',
role: r.role ?? 'developer',
llmId: r.llmId ?? null,
createdAt: NOW,
}) as AgentProject);
const repo = {
findByAgent: vi.fn(async (agentId: string) => rows.filter((r) => r.agentId === agentId)),
findByProject: vi.fn(async (projectId: string) => rows.filter((r) => r.projectId === projectId)),
find: vi.fn(async (agentId: string, projectId: string) =>
rows.find((r) => r.agentId === agentId && r.projectId === projectId) ?? null,
),
create: vi.fn(async (data: { agentId: string; projectId: string; role?: string; llmId?: string | null }) => {
const row = {
id: `ap-${String(idCounter++)}`,
agentId: data.agentId,
projectId: data.projectId,
role: data.role ?? 'developer',
llmId: data.llmId ?? null,
createdAt: NOW,
} as AgentProject;
rows = [...rows, row];
return row;
}),
update: vi.fn(async (id: string, data: { role?: string; llmId?: string | null }) => {
const row = rows.find((r) => r.id === id);
if (row === undefined) throw new Error(`no such membership: ${id}`);
if (data.role !== undefined) row.role = data.role;
if (data.llmId !== undefined) row.llmId = data.llmId;
return row;
}),
delete: vi.fn(async (id: string) => { rows = rows.filter((r) => r.id !== id); }),
} as unknown as IAgentProjectRepository & Record<string, ReturnType<typeof vi.fn>>;
const agents = {
getByName: vi.fn(async (name: string) => {
if (name === 'ghost') throw new NotFoundError(`Agent not found: ${name}`);
return { id: `agent-${name}`, name };
}),
getById: vi.fn(async (id: string) => ({ id, name: id.replace(/^agent-/, '') })),
} as unknown as AgentService;
const projects = {
resolveAndGet: vi.fn(async (name: string) => {
if (name === 'nowhere') throw new NotFoundError(`Project not found: ${name}`);
return { id: `proj-${name}`, name };
}),
getById: vi.fn(async (id: string) => ({ id, name: id.replace(/^proj-/, '') })),
} as unknown as ProjectService;
const llms = {
getByName: vi.fn(async (name: string) => {
if (name === 'missing-brain') throw new NotFoundError(`Llm not found: ${name}`);
return { id: `llm-${name}`, name };
}),
getById: vi.fn(async (id: string) => ({ id, name: id.replace(/^llm-/, '') })),
} as unknown as LlmService;
return {
repo, agents, projects, llms,
service: new AgentProjectService(repo, agents, projects, llms),
all: () => rows,
};
}
describe('AgentProjectService — joining and leaving', () => {
beforeEach(() => { vi.clearAllMocks(); });
it('joins a project with the default role', async () => {
const { service } = makeHarness();
const m = await service.join('alice', 'sre', {});
expect(m.project.name).toBe('sre');
expect(m.agent.name).toBe('alice');
expect(m.role).toBe('developer');
expect(m.llm).toBeNull();
});
it('joins the SAME agent to several projects — the whole point of the junction', async () => {
const { service } = makeHarness();
await service.join('alice', 'sre', {});
await service.join('alice', 'payments', { role: 'security' });
const memberships = await service.listForAgent('alice');
expect(memberships.map((m) => m.project.name).sort()).toEqual(['payments', 'sre']);
expect(memberships.find((m) => m.project.name === 'payments')?.role).toBe('security');
});
it('re-joining updates the role instead of 409-ing — repeat applies converge', async () => {
const { service, repo, all } = makeHarness();
await service.join('alice', 'sre', { role: 'developer' });
const second = await service.join('alice', 'sre', { role: 'qa' });
expect(second.role).toBe('qa');
expect(all()).toHaveLength(1);
expect(repo.create).toHaveBeenCalledTimes(1);
expect(repo.update).toHaveBeenCalledTimes(1);
});
it('rejects a role outside the v1 set', async () => {
const { service } = makeHarness();
await expect(service.join('alice', 'sre', { role: 'chief-vibes-officer' })).rejects.toThrow();
});
it('accepts every v1 role', async () => {
const { service } = makeHarness();
for (const role of AgentProjectService.roles()) {
const m = await service.join('alice', 'sre', { role });
expect(m.role).toBe(role);
}
});
it('rejects unknown body fields rather than silently dropping them', async () => {
const { service } = makeHarness();
// A typo'd `llmName` would otherwise leave the membership with no brain
// override and no complaint — the exact failure the strict schema exists for.
await expect(service.join('alice', 'sre', { llmName: 'x' })).rejects.toThrow();
});
it('404s on an unknown agent and on an unknown project', async () => {
const { service } = makeHarness();
await expect(service.join('ghost', 'sre', {})).rejects.toThrow(/Agent not found/);
await expect(service.join('alice', 'nowhere', {})).rejects.toThrow(/Project not found/);
});
it('leaves a project', async () => {
const { service, all } = makeHarness([{ agentId: 'agent-alice', projectId: 'proj-sre' }]);
await service.leave('alice', 'sre');
expect(all()).toHaveLength(0);
});
it('404s when leaving a project the agent never joined — a typo must not no-op', async () => {
const { service } = makeHarness();
await expect(service.leave('alice', 'sre')).rejects.toThrow(/is not a member of project 'sre'/);
});
});
describe('AgentProjectService — the per-membership brain override', () => {
beforeEach(() => { vi.clearAllMocks(); });
it('records the named Llm', async () => {
const { service } = makeHarness();
const m = await service.join('alice', 'sre', { llm: 'qwen3-thinking' });
expect(m.llm).toEqual({ id: 'llm-qwen3-thinking', name: 'qwen3-thinking' });
});
it('clears the override with an explicit null', async () => {
const { service } = makeHarness();
await service.join('alice', 'sre', { llm: 'qwen3-thinking' });
const cleared = await service.join('alice', 'sre', { llm: null });
expect(cleared.llm).toBeNull();
});
it('leaves an existing override untouched when `llm` is absent', async () => {
const { service } = makeHarness();
await service.join('alice', 'sre', { llm: 'qwen3-thinking' });
const roleOnly = await service.join('alice', 'sre', { role: 'qa' });
expect(roleOnly.role).toBe('qa');
expect(roleOnly.llm?.name).toBe('qwen3-thinking');
});
it('404s on an Llm that does not exist', async () => {
const { service } = makeHarness();
await expect(service.join('alice', 'sre', { llm: 'missing-brain' })).rejects.toThrow(/Llm not found/);
});
it('survives a brain that vanished under the membership', async () => {
// onDelete: SetNull should prevent this, but a torn read must not 500 the
// whole roster.
const { service, llms } = makeHarness([{ agentId: 'agent-alice', projectId: 'proj-sre', llmId: 'llm-gone' }]);
(llms.getById as ReturnType<typeof vi.fn>).mockRejectedValueOnce(new NotFoundError('Llm not found: llm-gone'));
const [m] = await service.listForAgent('alice');
expect(m?.llm).toBeNull();
});
});
describe('AgentProjectService — the reverse view', () => {
beforeEach(() => { vi.clearAllMocks(); });
it('lists the whole roster for a project, with roles', async () => {
const { service } = makeHarness([
{ agentId: 'agent-alice', projectId: 'proj-sre', role: 'sre' },
{ agentId: 'agent-bob', projectId: 'proj-sre', role: 'qa' },
{ agentId: 'agent-carol', projectId: 'proj-payments', role: 'security' },
]);
const roster = await service.listForProject('sre');
expect(roster.map((m) => `${m.agent.name}:${m.role}`)).toEqual(['alice:sre', 'bob:qa']);
});
it('404s on an unknown project', async () => {
const { service } = makeHarness();
await expect(service.listForProject('nowhere')).rejects.toThrow(/Project not found/);
});
});