feat(mcpd): make AgentProject real — an agent joins many projects

The AgentProject junction has existed since the data-model commit and nothing
read it. `grep -c agentProject src/mcpd/src/services/chat.service.ts` returned
0; chat resolved an agent's project from the legacy single `Agent.projectId`
scalar. So the schema claimed "an agent is a person who joins zero, one, or
many projects" (docs/agentic-teams.md §4) while the running system still gave
every agent exactly one project. A schema that lies about behaviour is worse
than a missing feature: it reads as done, so nobody builds it.

This makes the junction load-bearing.

Membership is now a real surface (task 152):

  GET    /api/v1/agents/:name/projects          memberships
  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

`role` is TEXT (developer|qa|security|sre in v1) and is validated in the
service layer, not the database — production provisions with `prisma db push`,
so a CHECK constraint written into a migration would never run there. Adding a
fifth role stays a one-line change.

RBAC: joining/leaving is `edit:agents:<name>`, not the `create:agents` the
default method→action mapping would have produced. Minting a whole new agent
and adding an existing one to a team are not the same authority; this mirrors
project↔server attach/detach already being `edit:projects`. Reading a roster is
`view:` on the side you asked from.

Chat honours it. `prepareContext` resolves the project from the membership when
the caller names one, and the resolution runs *before* the Llm lookup so a
per-membership `llmId` can replace the agent's pinned brain — and therefore its
whole failover pool — rather than patching the dispatch list afterwards. That
override is why the brain-ownership question (§15 Q1) can still go either way
without a migration: today the answer is per membership.

Naming a project the agent has not joined is an error that names the fix, not a
silent fallback. Answering from a *different* project's tools and prompts than
the one asked for is the failure mode worth being loud about.

The legacy scalar keeps working this release, as the schema comment promises:
`--project` accepts a membership *or* a legacy pin naming that same project,
bare `mcpctl chat <agent>` is unchanged, and `GET /projects/:p/agents` returns
the union of members and legacy-pinned agents so nothing disappears from a
project mid-migration (mcplocal's agents plugin reads that endpoint).

CLI: `mcpctl chat <agent> --project <p>` (previously rejected as "not both"),
plus `attach-agent` / `detach-agent` alongside the existing attach-server pair,
`get agents --project`, and `get memberships` with ROLE and BRAIN columns.
Completions come from the generator, not hand-edits.

Threads stay scoped to the agent, not to (agent, project) — §4 wants the
latter, but that needs a schema change and a story for what happens to those
threads when a membership is revoked. Deliberately not decided here.

Tests: 51 new (43 mcpd, 8 CLI). Full suite 2514 pass. Lint unchanged at
794 errors / 77 warnings.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Michal
2026-08-09 22:14:45 +01:00
parent 5f5efd1144
commit 1a28cc5098
26 changed files with 1731 additions and 27 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' });
});
});