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>
12 KiB
Agents
An Agent is an LLM persona pinned to a specific Llm, with a system prompt,
a description that surfaces in MCP tools/list, optional attachment to a
Project, and LiteLLM-style sampling defaults. Conversations are persisted
as ChatThread + ChatMessage rows so REPL sessions resume across runs.
Two surfaces use an agent:
-
Direct chat via
mcpctl chat <name>(interactive REPL or one-shot-m "msg"). Streams over SSE; tool calls and tool results print to stderr in dim brackets. Slash-commands/set,/system,/tools,/clear,/save,/quitadjust runtime behavior. -
Virtual MCP server registered into every project session by mcplocal's agents plugin. The agent shows up as
agent-<name>with one toolchat, whose description is the agent's own description. Other Claude sessions / MCP clients see the agent as just another tool intools/listand can consult it.
Data model
Three Prisma models added to src/db/prisma/schema.prisma:
-
Agent—name(unique),description,systemPrompt,llmId(FK Restrict — an Llm in active use cannot be deleted),projectId(FK SetNull — agents survive project deletion),proxyModelName(optional informational override),defaultParams(Json, LiteLLM-style),extras(Json, reserved for future LoRA / tool allowlists),ownerId, version, timestamps. -
ChatThread—agentId,ownerId,title,lastTurnAt, timestamps. Cascade delete on agent. -
ChatMessage—threadId,turnIndex(monotonic per thread, enforced by@@unique([threadId, turnIndex])),role('system' | 'user' | 'assistant' | 'tool'),content,toolCalls(Json — assistant turn's[{id,name,arguments}]),toolCallId(which call a tool turn answers),status('pending' | 'complete' | 'error'),createdAt. Cascade delete on thread.
status stays pending while the orchestrator runs an in-flight assistant
or tool turn, then flips to complete once the round settles. On any
exception in the chat loop, every pending row in the thread is flipped to
error so the trail stays auditable.
Chat parameters (LiteLLM-style passthrough)
Per-call resolution: request body → agent.defaultParams → adapter default.
Setting a key to null in the request explicitly clears a default.
| Key | Type | Notes |
|---|---|---|
temperature |
number | 0..2 |
top_p |
number | 0..1 |
top_k |
integer | Anthropic-only; OpenAI ignores |
max_tokens |
integer | adapter clamps to provider max |
stop |
string | string[] | up to 4 sequences |
presence_penalty |
number | OpenAI |
frequency_penalty |
number | OpenAI |
seed |
integer | reproducibility (provider-dependent) |
response_format |
object | text | json_object | json_schema |
tool_choice |
enum/object | auto|none|required|{type:'function',function:{name}} |
tools_allowlist |
string[] | restricts which project MCP tools the agent can call this turn |
systemOverride |
string | replaces agent.systemPrompt for this call |
systemAppend |
string | concatenated to system block (after project Prompts) |
messages |
array | full message history override; if set, message/threadId history is ignored |
extra |
object | provider-specific knobs (Anthropic metadata.user_id, vLLM repetition_penalty) — adapters cherry-pick |
HTTP API (mcpd)
GET /api/v1/agents list (RBAC: view:agents)
GET /api/v1/agents/:idOrName describe (view:agents)
POST /api/v1/agents create (create:agents)
PUT /api/v1/agents/:idOrName update (edit:agents)
DELETE /api/v1/agents/:idOrName delete (delete:agents)
POST /api/v1/agents/:name/chat chat — non-streaming or SSE (run:agents:<name>)
POST /api/v1/agents/:name/threads create thread (run:agents:<name>)
GET /api/v1/agents/:name/threads list threads (run:agents:<name>)
GET /api/v1/threads/:id/messages replay history (view:agents)
GET /api/v1/projects/:p/agents project roster — members + legacy pins (view:projects:<p>)
GET /api/v1/agents/:name/projects memberships (view:agents:<name>)
POST /api/v1/agents/:name/projects/:p join, body {role?, llm?} (edit:agents:<name>)
DELETE /api/v1/agents/:name/projects/:p leave (edit:agents:<name>)
GET /api/v1/projects/:p/memberships roster with roles (view:projects:<p>)
The chat endpoint reuses the SSE pattern from llm-infer.ts exactly: same
headers (text/event-stream, X-Accel-Buffering: no), same data: …\n\n
framing, same [DONE] terminator. SSE chunk types:
{type:'text', delta}— assistant text increments{type:'tool_call', toolName, args}— model decided to call a tool{type:'tool_result', toolName, ok}— tool dispatch outcome{type:'final', threadId, turnIndex}— terminal turn{type:'error', message}— fatal error in the loop
Team membership (an agent joins many projects)
An agent is a person, not a project's property: it joins zero, one, or many
projects (docs/agentic-teams.md §4). Membership lives in the AgentProject
junction and carries two per-project facts the Agent row cannot:
role— the persona worn on this team. v1:developer(default),qa,security,sre. TEXT in the schema, validated in the service layer, so adding a role later is not a migration.llm— an optional brain override. When set, that Llm answers for this project instead of the agent's pinned one; the agent keeps its own brain everywhere else.
mcpctl attach-agent alice --project sre --role sre --llm qwen3-thinking
mcpctl attach-agent alice --project payments --role security
mcpctl get memberships alice # every team alice is on
mcpctl get memberships --project sre # the sre roster, with roles
mcpctl get agents --project sre # the same roster as plain agent rows
mcpctl detach-agent alice --project sre
Joining is idempotent — re-running attach-agent updates the role/brain
instead of failing, so repeated applies converge.
Transition: the legacy single Agent.projectId pin still works this
release. mcpctl chat <agent> --project <p> accepts a membership or a legacy
pin naming that same project; anything else is an error rather than a silent
answer from the wrong project's context. GET /projects/:p/agents returns the
union of both, so nothing disappears from a project mid-migration.
Tool-use loop
When the agent's project has MCP servers attached, mcpd's ChatService lists
each server's tools (via mcp-proxy.service.ts — same path real MCP traffic
uses) and presents them to the model namespaced as <server>__<tool>. On a
tool_calls response the loop dispatches each call back through the same
proxy, persists the assistant + tool turns linked by toolCallId, and loops
(cap = 12 iterations) until the model returns terminal text.
Persistence is non-transactional across the loop because tool calls can take minutes; long-held DB transactions would starve other writers.
RBAC
Agents are their own resource (agents), independent of project bindings.
Recommended:
view:agents— list / describecreate:agents/edit:agents/delete:agents— CRUDrun:agents:<name>— drive a chat turn or manage its threadsedit:agents:<name>— join/leave a project team (NOTcreate:agents, which mints whole agents; this mirrors project↔server attach/detach beingedit)
Project-attached agents do not implicitly inherit project RBAC. If a project
member should be able to chat with the project's agents, grant them
run:agents:<each-name> (or wildcard run:agents) explicitly. The reverse also
holds: membership grants the agent project context, not the caller project
permissions — run:agents:<name> is still the only check on
mcpctl chat <agent> --project <p>.
YAML round-trip
get agent foo -o yaml | mcpctl apply -f - is a no-op. The apply schema
also accepts shorthand:
apiVersion: mcpctl.io/v1
kind: agent
metadata: { name: deployer }
spec:
description: "I help you deploy code"
llm: qwen3-thinking # shorthand for `{ name: qwen3-thinking }`
project: mcpctl-dev # shorthand for `{ name: mcpctl-dev }`
systemPrompt: |
You are a deployment assistant for mcpctl. Always check fulldeploy.sh
and the k8s context before suggesting actions.
defaultParams:
temperature: 0.2
max_tokens: 4096
top_p: 0.9
stop: ["</deploy>"]
Wiring against your in-cluster qwen3-thinking
The kubernetes-deployment repo provisions LiteLLM in the nvidia-nim
namespace (http://litellm.nvidia-nim.svc.cluster.local:4000/v1 in-cluster,
https://llm.ad.itaz.eu/v1 external) and a virtual key reserved for mcpctl
in the Pulumi secret secrets:litellmMcpctlGatewayToken. Pulling it once:
cd /path/to/kubernetes-deployment
LITELLM_TOKEN=$(pulumi config get --stack homelab secrets:litellmMcpctlGatewayToken)
# fallback if Pulumi isn't authed locally:
# LITELLM_TOKEN=$(kubectl --context worker0-k8s0 -n nvidia-nim get secret litellm-secrets \
# -o jsonpath='{.data.LITELLM_MCPCTL_GATEWAY_TOKEN}' | base64 -d)
cd /path/to/mcpctl
mcpctl create secret litellm-key --data "API_KEY=${LITELLM_TOKEN}"
mcpctl create llm qwen3-thinking \
--type openai --model qwen3-thinking \
--url http://litellm.nvidia-nim.svc.cluster.local:4000/v1 \
--api-key-ref litellm-key/API_KEY \
--description "Qwen3-30B-A3B-Thinking-FP8 via in-cluster vLLM behind LiteLLM"
mcpctl create agent reviewer \
--llm qwen3-thinking \
--description "I review what you're shipping; ask after each major change." \
--default-temperature 0.2 --default-max-tokens 4096
mcpctl chat reviewer
Troubleshooting
-
Namespace collision in mcplocal: if a project has an upstream MCP server literally named
agent-<x>, the agents plugin detects the collision inonSessionCreate, skips that agent's registration, and emits actx.log.warnline. Document theagent-prefix as reserved on real server names. -
Llm-in-use blocks delete:
Agent.llmisonDelete: Restrict. Detach every agent (or delete them) before deleting the underlying Llm. -
Stale
pendingrows: a crash mid-loop leavespendingChatMessage rows. The next request recovers —markPendingAsErrorflips them on the next failure path, andloadHistoryfilters outerrorrows when rebuilding context for the next turn. -
proxyModelNameis informational only for agents. The agent's own internal tool loop runs server-side in mcpd and bypasses mcplocal's proxymodel pipeline entirely. Don't try to plumb it. -
Anthropic + tools: the Anthropic adapter currently drops
toolrole messages and doesn't translate OpenAItool_callsto Anthropictool_use/tool_resultblocks. Use an OpenAI-compatible provider (LiteLLM, vLLM, OpenAI) for agents that need tool calling until that translation lands.
See also
- personalities.md — named overlays of prompts on
top of an agent. Same agent, different prompt bundles, picked per-turn
via
--personality <name>oragent.defaultPersonality. - virtual-llms.md — local LLMs (e.g.
vllm-local) publishing themselves intomcpctl get llmso anyone can chat with them viamcpctl chat-llm <name>. Inference is relayed through the publishing mcplocal — mcpd never holds the local URL or key. v3 extends the same publishing model to virtual agents declared in mcplocal config — they show up inmcpctl get agentwithKIND=virtual / STATUS=activeand become chat-able viamcpctl chat <name>like any other agent. v4 adds pools: Llms sharing apoolNamestack into one load-balanced pool that the chat dispatcher transparently widens to at request time, with random selection + sequential failover on transport errors. - inference-tasks.md — v5 durable task
queue underneath both the sync chat path and a new async API
(
POST /api/v1/inference-tasks). Tasks survive mcpd restart and worker disconnect; queued work drains when a worker comes online. Useful for fire-and-forget scripts and pools that don't have a worker available right now. - chat.md —
mcpctl chatflow and LiteLLM-style flags.