feat(chat): project-scoped chat — mcpctl chat --project <name>
Some checks failed
CI/CD / lint (pull_request) Successful in 1m2s
CI/CD / typecheck (pull_request) Successful in 2m10s
CI/CD / test (pull_request) Successful in 1m17s
CI/CD / smoke (pull_request) Failing after 1m49s
CI/CD / build (pull_request) Successful in 2m8s
CI/CD / publish (pull_request) Has been skipped

Chat directly with a Project (no Agent needed): its Prompts become the system
context, its MCP-server tools are callable, its llmProvider/llmModel drive the
LLM, and (opt-in) the model can read secret values. History is saved inside the
project, attributed per user, resumable, and deletable (RBAC-permitting) — "use
it like Claude, scoped to the project".

Backend (reuses the agent-chat orchestrator):
- ChatThread is now agent-XOR-project (schema + migration + CHECK constraint);
  new listThreadsByProject / deleteThread on the repo.
- ChatService: prepareProjectContext (project prompt + Prompts by priority,
  llm from llmProvider with llmModel override, project tools), shared
  runChatLoop/runChatStreamLoop, project thread CRUD with owner enforcement
  (404-not-403 on foreign threads), admin-override delete.
- Gated get_secret virtual tool: offered only with --allow-secrets AND the
  caller's view:secrets; resolves via SecretService, never routes to a server.
- routes/project-chat.ts (chat SSE+non-stream, threads create/list/delete);
  RBAC run:projects:<name>.

CLI:
- `mcpctl chat --project <name>` (+ --allow-secrets), one-shot/REPL/resume.
- REPL /threads, /resume <id>, /delete <id>; project-aware header + /tools.
- `mcpctl get threads --project <name>`, `mcpctl delete thread <id> --project`.
- completions regenerated (--project completes project names).

Tests: 8 new project-chat unit tests; full mcpd (945) + CLI (508) green;
schema validated against Postgres. Docs: docs/chat.md "Project chat" section.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Michal
2026-07-18 10:34:21 +01:00
parent ae66ad8430
commit a5cab5a096
15 changed files with 995 additions and 84 deletions

View File

@@ -38,14 +38,16 @@ export interface ChatCommandDeps {
export function createChatCommand(deps: ChatCommandDeps): Command {
return new Command('chat')
.description('Open an interactive chat session with an agent (REPL or one-shot).')
.argument('<agent>', 'Agent name (see `mcpctl get agents`)')
.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('--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')
.option('--system <text>', 'Replace agent.systemPrompt for this session')
.option('--system <text>', 'Replace the base system prompt for this session')
.option('--system-file <path>', 'Read --system text from a file')
.option('--system-append <text>', 'Append to the agent system block for this session')
.option('--personality <name>', 'Personality overlay (additive prompts on top of the agent)')
.option('--system-append <text>', 'Append to the system block for this session')
.option('--personality <name>', 'Personality overlay (agent chat only)')
.option('--temperature <n>', 'Sampling temperature (0..2)', parseFloat)
.option('--top-p <n>', 'Nucleus sampling cutoff (0..1)', parseFloat)
.option('--top-k <n>', 'Top-K sampling (Anthropic; OpenAI ignores)', parseFloatInt)
@@ -55,18 +57,61 @@ export function createChatCommand(deps: ChatCommandDeps): Command {
.option('--allow-tool <name>', 'Restrict to this tool only (repeatable)', collect, [])
.option('--extra <kv>', 'Provider-specific knob k=v (repeatable)', collect, [])
.option('--no-stream', 'Disable SSE streaming (single JSON response)')
.action(async (agent: string, opts: ChatOpts) => {
.action(async (agent: string | undefined, opts: ChatOpts) => {
const subject = resolveSubject(agent, opts);
const overrides = await buildInitialOverrides(opts);
if (opts.message !== undefined) {
await runOneShot(deps, agent, opts.message, opts.thread, overrides, opts.stream);
await runOneShot(deps, subject, opts.message, opts.thread, overrides, opts.stream);
return;
}
await runRepl(deps, agent, opts.thread, overrides, opts.stream);
await runRepl(deps, subject, opts.thread, overrides, opts.stream);
});
}
/** What the chat is bound to: a named Agent or a Project. */
interface ChatSubject {
kind: 'agent' | 'project';
name: string;
/** URL segment, e.g. `agents/reviewer` or `projects/sre` (name url-encoded). */
path: string;
/** Project chat only: opt into the gated get_secret tool. */
allowSecrets: boolean;
}
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 (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`.');
}
/**
* Build the chat request body for a subject. Project chat omits the agent-only
* `personality` overlay (the project schema rejects unknown fields) and adds
* `allowSecrets` when requested.
*/
function chatBody(subject: ChatSubject, message: string, threadId: string | undefined, overrides: Overrides, stream?: boolean): Record<string, unknown> {
const o: Record<string, unknown> = { ...overrides };
if (subject.kind === 'project') {
delete o.personality;
if (subject.allowSecrets) o.allowSecrets = true;
}
const body: Record<string, unknown> = { message, ...o };
if (threadId !== undefined) body.threadId = threadId;
if (stream === true) body.stream = true;
return body;
}
interface ChatOpts {
project?: string;
allowSecrets?: boolean;
message?: string;
thread?: string;
system?: string;
@@ -136,18 +181,17 @@ function parseExtraValue(raw: string): unknown {
async function runOneShot(
deps: ChatCommandDeps,
agent: string,
subject: ChatSubject,
message: string,
threadId: string | undefined,
overrides: Overrides,
stream: boolean | undefined,
): Promise<void> {
await printChatHeader(deps, agent, overrides);
await printChatHeader(deps, subject, overrides);
if (stream === false) {
const body: Record<string, unknown> = { message, ...overrides };
if (threadId !== undefined) body.threadId = threadId;
const body = chatBody(subject, message, threadId, overrides);
const startMs = Date.now();
const res = await chatRequestNonStream(deps, agent, body);
const res = await chatRequestNonStream(deps, subject, body);
const sec = Math.max(0.05, (Date.now() - startMs) / 1000);
const words = (res.assistant.match(/\S+/g) ?? []).length;
process.stdout.write(`${res.assistant}\n`);
@@ -159,7 +203,7 @@ async function runOneShot(
}
const bar = installStatusBar();
try {
const finalThread = await streamOnce(deps, agent, message, threadId, overrides, bar);
const finalThread = await streamOnce(deps, subject, message, threadId, overrides, bar);
process.stderr.write(`\n(thread: ${finalThread})\n`);
} finally {
bar?.teardown();
@@ -168,7 +212,7 @@ async function runOneShot(
async function runRepl(
deps: ChatCommandDeps,
agent: string,
subject: ChatSubject,
initialThread: string | undefined,
initialOverrides: Overrides,
stream: boolean | undefined,
@@ -178,14 +222,14 @@ async function runRepl(
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
const ask = (q: string): Promise<string> => new Promise((resolve) => rl.question(q, resolve));
await printChatHeader(deps, agent, overrides);
await printChatHeader(deps, subject, overrides);
// The status bar persists across turns inside a REPL — it shows the last
// response's final rate between messages, then refreshes live during the
// next stream. Only enabled for streaming mode (no rate to show otherwise).
const bar = stream === false ? null : installStatusBar();
process.stderr.write(`Slash commands: /set /system /tools /clear /save /quit. Ctrl-D to exit.\n`);
process.stderr.write(`Slash commands: /threads /resume /delete /set /system /tools /clear /save /quit. Ctrl-D to exit.\n`);
if (threadId !== undefined) {
process.stderr.write(`(resuming thread ${threadId})\n`);
}
@@ -200,20 +244,22 @@ async function runRepl(
}
if (line === '') continue;
if (line.startsWith('/')) {
const handled = await handleSlash(line, deps, agent, overrides, () => { threadId = undefined; });
const handled = await handleSlash(line, deps, subject, overrides, {
get: () => threadId,
set: (id) => { threadId = id; },
});
if (handled === 'quit') break;
continue;
}
try {
if (stream === false) {
const body: Record<string, unknown> = { message: line, ...overrides };
if (threadId !== undefined) body.threadId = threadId;
const res = await chatRequestNonStream(deps, agent, body);
const body = chatBody(subject, line, threadId, overrides);
const res = await chatRequestNonStream(deps, subject, body);
threadId = res.threadId;
process.stdout.write(`${res.assistant}\n`);
} else {
threadId = await streamOnce(deps, agent, line, threadId, overrides, bar);
threadId = await streamOnce(deps, subject, line, threadId, overrides, bar);
process.stdout.write('\n');
}
} catch (err) {
@@ -226,12 +272,17 @@ async function runRepl(
}
}
interface ThreadControl {
get(): string | undefined;
set(id: string | undefined): void;
}
async function handleSlash(
raw: string,
deps: ChatCommandDeps,
agent: string,
subject: ChatSubject,
overrides: Overrides,
resetThread: () => void,
thread: ThreadControl,
): Promise<'quit' | 'continue'> {
const [cmd, ...rest] = raw.slice(1).split(/\s+/);
switch (cmd) {
@@ -239,9 +290,59 @@ async function handleSlash(
case 'exit':
return 'quit';
case 'clear':
resetThread();
process.stderr.write('(new thread\n)');
thread.set(undefined);
process.stderr.write('(new thread)\n');
return 'continue';
case 'threads': {
try {
const rows = await deps.client.get<Array<{ id: string; title: string; lastTurnAt: string }>>(
`/api/v1/${subject.path}/threads`,
);
if (rows.length === 0) {
process.stderr.write('(no saved threads yet)\n');
} else {
const active = thread.get();
for (const r of rows) {
const mark = r.id === active ? '* ' : ' ';
const title = r.title !== '' ? `${r.title}` : '';
process.stderr.write(`${mark}${r.id} ${new Date(r.lastTurnAt).toLocaleString()}${title}\n`);
}
process.stderr.write('(/resume <id> to switch, /delete <id> to remove)\n');
}
} catch (err) {
process.stderr.write(`error listing threads: ${(err as Error).message}\n`);
}
return 'continue';
}
case 'resume': {
const id = rest[0];
if (id === undefined || id === '') {
process.stderr.write('usage: /resume <thread-id> (see /threads)\n');
} else {
thread.set(id);
process.stderr.write(`(resuming thread ${id})\n`);
}
return 'continue';
}
case 'delete': {
const id = rest[0];
if (id === undefined || id === '') {
process.stderr.write('usage: /delete <thread-id> (see /threads)\n');
return 'continue';
}
if (subject.kind !== 'project') {
process.stderr.write('(/delete is project-chat only)\n');
return 'continue';
}
try {
await deps.client.delete(`/api/v1/projects/${encodeURIComponent(subject.name)}/threads/${encodeURIComponent(id)}`);
process.stderr.write(`(deleted thread ${id})\n`);
if (thread.get() === id) thread.set(undefined);
} catch (err) {
process.stderr.write(`error deleting thread: ${(err as Error).message}\n`);
}
return 'continue';
}
case 'system': {
const text = rest.join(' ');
if (text === '') {
@@ -265,15 +366,21 @@ async function handleSlash(
}
case 'tools': {
try {
const a = await deps.client.get<{ project: { name: string } | null }>(
`/api/v1/agents/${encodeURIComponent(agent)}`,
);
if (a.project === null) {
let projectName: string | null;
if (subject.kind === 'project') {
projectName = subject.name;
} else {
const a = await deps.client.get<{ project: { name: string } | null }>(
`/api/v1/agents/${encodeURIComponent(subject.name)}`,
);
projectName = a.project?.name ?? null;
}
if (projectName === null) {
process.stderr.write('(agent has no project — no tools available)\n');
return 'continue';
}
const servers = await deps.client.get<Array<{ server: { name: string } }>>(
`/api/v1/projects/${encodeURIComponent(a.project.name)}/servers`,
`/api/v1/projects/${encodeURIComponent(projectName)}/servers`,
);
if (servers.length === 0) {
process.stderr.write('(project has no MCP servers attached)\n');
@@ -281,6 +388,9 @@ async function handleSlash(
for (const s of servers) {
process.stderr.write(` ${s.server.name}\n`);
}
if (subject.kind === 'project' && subject.allowSecrets) {
process.stderr.write(' secrets (get_secret — reads secret values)\n');
}
}
} catch (err) {
process.stderr.write(`error listing tools: ${(err as Error).message}\n`);
@@ -288,8 +398,12 @@ async function handleSlash(
return 'continue';
}
case 'save': {
if (subject.kind === 'project') {
process.stderr.write('(/save is agent-only — project chats have no persisted defaults)\n');
return 'continue';
}
try {
await deps.client.put(`/api/v1/agents/${encodeURIComponent(agent)}`, {
await deps.client.put(`/api/v1/agents/${encodeURIComponent(subject.name)}`, {
defaultParams: stripSession(overrides),
});
process.stderr.write('(saved current overrides as agent.defaultParams)\n');
@@ -339,10 +453,10 @@ function applySetCommand(o: Overrides, key: string, valueRaw: string): void {
*/
async function chatRequestNonStream(
deps: ChatCommandDeps,
agent: string,
subject: ChatSubject,
body: Record<string, unknown>,
): Promise<{ assistant: string; threadId: string; turnIndex: number }> {
const url = new URL(`${deps.baseUrl}/api/v1/agents/${encodeURIComponent(agent)}/chat`);
const url = new URL(`${deps.baseUrl}/api/v1/${subject.path}/chat`);
const payload = JSON.stringify(body);
return new Promise((resolve, reject) => {
const driver = url.protocol === 'https:' ? https : http;
@@ -388,14 +502,14 @@ async function chatRequestNonStream(
/** Stream a single chat call. Returns the resolved threadId. */
async function streamOnce(
deps: ChatCommandDeps,
agent: string,
subject: ChatSubject,
message: string,
threadId: string | undefined,
overrides: Overrides,
bar: StatusBar | null = null,
): Promise<string> {
const url = new URL(`${deps.baseUrl}/api/v1/agents/${encodeURIComponent(agent)}/chat`);
const body = JSON.stringify({ message, threadId, stream: true, ...overrides });
const url = new URL(`${deps.baseUrl}/api/v1/${subject.path}/chat`);
const body = JSON.stringify(chatBody(subject, message, threadId, overrides, true));
// Per-response counters. Updated on every text/thinking delta, surfaced
// live through the bottom-row status bar and the final stats footer.
@@ -683,6 +797,14 @@ interface AgentInfo {
defaultPersonality?: { name: string } | null;
}
interface ProjectInfo {
name: string;
description: string;
prompt?: string;
llmProvider?: string | null;
llmModel?: string | null;
}
/**
* Prints a startup banner showing what the chat session will be running with:
* agent name, LLM, project, the assembled system prompt, and any session
@@ -693,22 +815,53 @@ interface AgentInfo {
*/
async function printChatHeader(
deps: ChatCommandDeps,
agent: string,
subject: ChatSubject,
overrides: Overrides,
): Promise<void> {
let info: AgentInfo;
try {
info = await deps.client.get<AgentInfo>(`/api/v1/agents/${encodeURIComponent(agent)}`);
} catch (err) {
process.stderr.write(`(could not fetch agent metadata: ${(err as Error).message})\n`);
return;
}
const sep = '─'.repeat(60);
const out = (s: string): void => { process.stderr.write(`${styleStats(s)}\n`); };
const indent = (text: string): string =>
text.split('\n').map((l) => ` ${l}`).join('\n');
if (subject.kind === 'project') {
let proj: ProjectInfo;
try {
proj = await deps.client.get<ProjectInfo>(`/api/v1/projects/${encodeURIComponent(subject.name)}`);
} catch (err) {
process.stderr.write(`(could not fetch project metadata: ${(err as Error).message})\n`);
return;
}
out(sep);
out(`Project: ${proj.name}${proj.description !== '' ? `${proj.description}` : ''}`);
const model = proj.llmModel !== null && proj.llmModel !== undefined && proj.llmModel !== '' ? ` (${proj.llmModel})` : '';
out(`LLM: ${proj.llmProvider ?? '(none set — set project.llmProvider)'}${model}`);
if (overrides.systemOverride !== undefined) {
out('System prompt (--system replaces project.prompt):');
out(indent(overrides.systemOverride));
} else if (proj.prompt !== undefined && proj.prompt !== '') {
out('Project prompt:');
out(indent(proj.prompt));
}
if (overrides.systemAppend !== undefined) {
out('System append (--system-append):');
out(indent(overrides.systemAppend));
}
out('(project Prompts auto-appended at chat time; /tools lists MCP servers)');
if (subject.allowSecrets) out('Secret access: ENABLED (get_secret tool available — requires view:secrets)');
const so = describeSessionOverrides(overrides);
if (so !== '') out(`Sampling overrides: ${so}`);
out(sep);
return;
}
let info: AgentInfo;
try {
info = await deps.client.get<AgentInfo>(`/api/v1/agents/${encodeURIComponent(subject.name)}`);
} catch (err) {
process.stderr.write(`(could not fetch agent metadata: ${(err as Error).message})\n`);
return;
}
out(sep);
out(`Agent: ${info.name}${info.description !== '' ? `${info.description}` : ''}`);
const tail = info.project !== null ? ` Project: ${info.project.name}` : '';

View File

@@ -19,6 +19,17 @@ export function createDeleteCommand(deps: DeleteCommandDeps): Command {
.action(async (resourceArg: string, idOrName: string, opts: { project?: string; agent?: string }) => {
const resource = resolveResource(resourceArg);
// Chat threads: project-scoped, deleted by id. Owner can delete their own;
// admins with delete:projects can delete anyone's (enforced server-side).
if (resource === 'threads') {
if (!opts.project) {
throw new Error('--project is required. Usage: mcpctl delete thread <id> --project <name>');
}
await client.delete(`/api/v1/projects/${encodeURIComponent(opts.project)}/threads/${encodeURIComponent(idOrName)}`);
log(`thread '${idOrName}' deleted from project '${opts.project}'.`);
return;
}
// Serverattachments: delete serverattachment <server> --project <project>
if (resource === 'serverattachments') {
if (!opts.project) {

View File

@@ -234,6 +234,14 @@ const agentColumns: Column<AgentRow>[] = [
{ header: 'ID', key: 'id' },
];
interface ThreadRow { id: string; title: string; lastTurnAt: string; createdAt: string }
const threadColumns: Column<ThreadRow>[] = [
{ header: 'ID', key: 'id' },
{ header: 'TITLE', key: (r) => truncate(r.title, 40) || '(untitled)', width: 40 },
{ header: 'LAST TURN', key: (r) => formatAge(r.lastTurnAt), width: 10 },
{ header: 'CREATED', key: (r) => formatAge(r.createdAt), width: 10 },
];
interface PersonalityRow {
id: string;
name: string;
@@ -441,6 +449,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 'threads':
return threadColumns as unknown as Column<Record<string, unknown>>[];
case 'inference-tasks':
return inferenceTaskColumns as unknown as Column<Record<string, unknown>>[];
default:

View File

@@ -131,6 +131,14 @@ export function createProgram(): Command {
return client.get<unknown[]>(`/api/v1/mcptokens?projectName=${encodeURIComponent(projectName)}`);
}
// Chat threads are project-scoped history — `get threads --project <name>`.
if (!nameOrId && resource === 'threads') {
if (!projectName) {
throw new Error('threads are scoped — pass --project <name> (see `mcpctl chat --project`)');
}
return client.get<unknown[]>(`/api/v1/projects/${encodeURIComponent(projectName)}/threads`);
}
// Name-based lookup for mcptokens: names are unique only within a project
if (nameOrId && resource === 'mcptokens' && !/^c[a-z0-9]{24}/.test(nameOrId)) {
if (!projectName) {