Files
mcpctl/docs/agents.md
Michal 1a28cc5098 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>
2026-08-09 22:14:45 +01:00

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:

  1. 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, /quit adjust runtime behavior.

  2. Virtual MCP server registered into every project session by mcplocal's agents plugin. The agent shows up as agent-<name> with one tool chat, whose description is the agent's own description. Other Claude sessions / MCP clients see the agent as just another tool in tools/list and 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 / describe
  • create:agents / edit:agents / delete:agents — CRUD
  • run:agents:<name> — drive a chat turn or manage its threads
  • edit:agents:<name> — join/leave a project team (NOT create:agents, which mints whole agents; this mirrors project↔server attach/detach being edit)

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 in onSessionCreate, skips that agent's registration, and emits a ctx.log.warn line. Document the agent- prefix as reserved on real server names.

  • Llm-in-use blocks delete: Agent.llm is onDelete: Restrict. Detach every agent (or delete them) before deleting the underlying Llm.

  • Stale pending rows: a crash mid-loop leaves pending ChatMessage rows. The next request recovers — markPendingAsError flips them on the next failure path, and loadHistory filters out error rows when rebuilding context for the next turn.

  • proxyModelName is 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 tool role messages and doesn't translate OpenAI tool_calls to Anthropic tool_use / tool_result blocks. 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> or agent.defaultPersonality.
  • virtual-llms.md — local LLMs (e.g. vllm-local) publishing themselves into mcpctl get llm so anyone can chat with them via mcpctl 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 in mcpctl get agent with KIND=virtual / STATUS=active and become chat-able via mcpctl chat <name> like any other agent. v4 adds pools: Llms sharing a poolName stack 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 chat flow and LiteLLM-style flags.