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

@@ -29,6 +29,8 @@ type CompletionHint =
| 'instance_names'
| 'available_servers'
| 'project_servers'
| 'available_agents'
| 'project_agents'
| 'file'
| { static: string[] };
@@ -53,6 +55,8 @@ const COMPLETION_HINTS: Record<string, CompletionHint> = {
'logs.name': 'instance_names',
'attach-server.server-name': 'available_servers',
'detach-server.server-name': 'project_servers',
'attach-agent.agent-name': 'available_agents',
'detach-agent.agent-name': 'project_agents',
'apply.file': 'file',
};
@@ -66,7 +70,9 @@ const FILE_OPTIONS = new Set([
]);
/** Commands shown ONLY when --project/-p is on the command line. */
const PROJECT_ONLY_COMMANDS = new Set(['attach-server', 'detach-server', 'favourites']);
const PROJECT_ONLY_COMMANDS = new Set([
'attach-server', 'detach-server', 'attach-agent', 'detach-agent', 'favourites',
]);
/** Commands that appear in BOTH project and non-project context. */
const PROJECT_SCOPED_COMMANDS = new Set([
@@ -188,7 +194,7 @@ const CANONICAL_RESOURCES = [
'users', 'groups', 'rbac', 'prompts', 'promptrequests',
'serverattachments', 'proxymodels', 'inference-tasks',
// Agentic teams
'epics', 'epictasks', 'tasks', 'repos', 'gitproviders', 'all',
'epics', 'epictasks', 'tasks', 'repos', 'gitproviders', 'memberships', 'all',
];
const ALIAS_ENTRIES: [string, string][] = [
@@ -220,6 +226,7 @@ const ALIAS_ENTRIES: [string, string][] = [
['repo', 'repos'], ['repos', 'repos'], ['repository', 'repos'], ['repositories', 'repos'],
['gitprovider', 'gitproviders'], ['gitproviders', 'gitproviders'],
['forge', 'gitproviders'], ['gp', 'gitproviders'],
['membership', 'memberships'], ['memberships', 'memberships'], ['team', 'memberships'],
['all', 'all'],
];
@@ -452,6 +459,14 @@ function generateFish(root: CmdInfo): string {
emit("complete -c mcpctl -n \"__fish_seen_subcommand_from detach-server; and __mcpctl_needs_server_arg\" -a '(__mcpctl_project_servers)' -d 'Server'");
emit('');
// attach-agent / detach-agent — the AgentProject team roster
emit("# attach-agent: show agents NOT yet on the team (only if no agent arg yet)");
emit("complete -c mcpctl -n \"__fish_seen_subcommand_from attach-agent; and __mcpctl_needs_agent_arg\" -a '(__mcpctl_available_agents)' -d 'Agent'");
emit('');
emit("# detach-agent: show agents ON the team (only if no agent arg yet)");
emit("complete -c mcpctl -n \"__fish_seen_subcommand_from detach-agent; and __mcpctl_needs_agent_arg\" -a '(__mcpctl_project_agents)' -d 'Agent'");
emit('');
// apply: allow file completions for positional argument
emit("# apply: allow file completions for positional argument");
emit("complete -c mcpctl -n \"__fish_seen_subcommand_from apply\" -F");
@@ -603,6 +618,33 @@ function __mcpctl_available_servers
end
end
end
`);
emit(`# Agents already on the project team (for detach-agent)
function __mcpctl_project_agents
set -l proj (__mcpctl_get_project_value)
if test -z "$proj"
return
end
mcpctl --project $proj get agents -o json 2>/dev/null | jq -r '.[].name' 2>/dev/null
end
`);
emit(`# Agents NOT yet on the project team (for attach-agent)
function __mcpctl_available_agents
set -l proj (__mcpctl_get_project_value)
if test -z "$proj"
mcpctl get agents -o json 2>/dev/null | jq -r '.[].name' 2>/dev/null
return
end
set -l all (mcpctl get agents -o json 2>/dev/null | jq -r '.[].name' 2>/dev/null)
set -l joined (mcpctl --project $proj get agents -o json 2>/dev/null | jq -r '.[].name' 2>/dev/null)
for a in $all
if not contains -- $a $joined
echo $a
end
end
end
`);
emit(`# Instance names for logs
@@ -652,6 +694,27 @@ function __mcpctl_needs_server_arg
end
return 1
end
`);
emit(`# Helper: check if attach-agent/detach-agent already has an agent argument
function __mcpctl_needs_agent_arg
set -l tokens (commandline -opc)
set -l found_cmd false
for tok in $tokens
if $found_cmd
if not string match -q -- '-*' $tok
return 1 # agent arg already present
end
end
if contains -- $tok attach-agent detach-agent
set found_cmd true
end
end
if $found_cmd
return 0
end
return 1
end
`);
emit(`# Helper: check if a specific parent-child subcommand pair is active
@@ -994,6 +1057,41 @@ function emitBashCase(emit: (s: string) => void, cmd: CmdInfo, root: CmdInfo): v
return;
}
// attach-agent — the team roster, minus whoever already joined
if (name === 'attach-agent') {
emit(' attach-agent)');
emit(' if [[ $((cword - subcmd_pos)) -ne 1 ]]; then');
emit(` COMPREPLY=($(compgen -W "${optFlags}" -- "$cur"))`);
emit(' return');
emit(' fi');
emit(' local proj names all_agents proj_agents');
emit(' proj=$(_mcpctl_get_project_value)');
emit(' if [[ -n "$proj" ]]; then');
emit(" all_agents=$(mcpctl get agents -o json 2>/dev/null | jq -r '.[].name' 2>/dev/null)");
emit(" proj_agents=$(mcpctl --project \"$proj\" get agents -o json 2>/dev/null | jq -r '.[].name' 2>/dev/null)");
emit(' names=$(comm -23 <(echo "$all_agents" | sort) <(echo "$proj_agents" | sort))');
emit(' else');
emit(' names=$(_mcpctl_resource_names "agents")');
emit(' fi');
emit(' COMPREPLY=($(compgen -W "$names" -- "$cur"))');
emit(' return ;;');
return;
}
// detach-agent — only agents already on the team
if (name === 'detach-agent') {
emit(' detach-agent)');
emit(' if [[ $((cword - subcmd_pos)) -ne 1 ]]; then return; fi');
emit(' local proj names');
emit(' proj=$(_mcpctl_get_project_value)');
emit(' if [[ -n "$proj" ]]; then');
emit(" names=$(mcpctl --project \"$proj\" get agents -o json 2>/dev/null | jq -r '.[].name' 2>/dev/null)");
emit(' fi');
emit(' COMPREPLY=($(compgen -W "$names" -- "$cur"))');
emit(' return ;;');
return;
}
// apply: file completions
if (name === 'apply') {
emit(' apply)');