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' });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user