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:
@@ -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)`);
|
||||
}
|
||||
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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.
|
||||
|
||||
121
src/cli/tests/commands/agent-membership.test.ts
Normal file
121
src/cli/tests/commands/agent-membership.test.ts
Normal 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' });
|
||||
});
|
||||
});
|
||||
@@ -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({
|
||||
|
||||
@@ -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).
|
||||
|
||||
81
src/mcpd/src/repositories/agent-project.repository.ts
Normal file
81
src/mcpd/src/repositories/agent-project.repository.ts
Normal 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 } });
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
95
src/mcpd/src/routes/agent-projects.ts
Normal file
95
src/mcpd/src/routes/agent-projects.ts
Normal 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;
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
132
src/mcpd/src/services/agent-project.service.ts
Normal file
132
src/mcpd/src/services/agent-project.service.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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
|
||||
|
||||
32
src/mcpd/src/validation/agent-project.schema.ts
Normal file
32
src/mcpd/src/validation/agent-project.schema.ts
Normal 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>;
|
||||
@@ -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',
|
||||
|
||||
198
src/mcpd/tests/agent-membership.test.ts
Normal file
198
src/mcpd/tests/agent-membership.test.ts
Normal 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']);
|
||||
});
|
||||
});
|
||||
103
src/mcpd/tests/agent-project-routes.test.ts
Normal file
103
src/mcpd/tests/agent-project-routes.test.ts
Normal 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', {});
|
||||
});
|
||||
});
|
||||
180
src/mcpd/tests/chat-service-membership.test.ts
Normal file
180
src/mcpd/tests/chat-service-membership.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
@@ -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.',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
215
src/mcpd/tests/services/agent-project-service.test.ts
Normal file
215
src/mcpd/tests/services/agent-project-service.test.ts
Normal 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/);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user