From a5cab5a096bafbe3fd6abbd05aee14d2f00e2f5c Mon Sep 17 00:00:00 2001 From: Michal Date: Sat, 18 Jul 2026 10:34:21 +0100 Subject: [PATCH] =?UTF-8?q?feat(chat):=20project-scoped=20chat=20=E2=80=94?= =?UTF-8?q?=20`mcpctl=20chat=20--project=20`?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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:. CLI: - `mcpctl chat --project ` (+ --allow-secrets), one-shot/REPL/resume. - REPL /threads, /resume , /delete ; project-aware header + /tools. - `mcpctl get threads --project `, `mcpctl delete thread --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) --- completions/mcpctl.bash | 4 +- completions/mcpctl.fish | 10 +- docs/chat.md | 43 +++ src/cli/src/commands/chat.ts | 245 +++++++++++++--- src/cli/src/commands/delete.ts | 11 + src/cli/src/commands/get.ts | 10 + src/cli/src/index.ts | 8 + .../migration.sql | 17 ++ src/db/prisma/schema.prisma | 11 +- src/mcpd/src/main.ts | 13 + src/mcpd/src/repositories/chat.repository.ts | 29 +- src/mcpd/src/routes/project-chat.ts | 150 ++++++++++ src/mcpd/src/services/chat.service.ts | 269 ++++++++++++++++-- src/mcpd/src/validation/agent.schema.ts | 27 ++ src/mcpd/tests/chat-service-project.test.ts | 232 +++++++++++++++ 15 files changed, 995 insertions(+), 84 deletions(-) create mode 100644 src/db/prisma/migrations/20260717234728_add_project_chat_threads/migration.sql create mode 100644 src/mcpd/src/routes/project-chat.ts create mode 100644 src/mcpd/tests/chat-service-project.test.ts diff --git a/completions/mcpctl.bash b/completions/mcpctl.bash index 0390dfb..e7ecdbc 100644 --- a/completions/mcpctl.bash +++ b/completions/mcpctl.bash @@ -245,9 +245,9 @@ _mcpctl() { if [[ $((cword - subcmd_pos)) -eq 1 ]]; then local names names=$(_mcpctl_resource_names "agents") - COMPREPLY=($(compgen -W "$names -m --message --thread --system --system-file --system-append --personality --temperature --top-p --top-k --max-tokens --seed --stop --allow-tool --extra --no-stream -h --help" -- "$cur")) + COMPREPLY=($(compgen -W "$names -p --project --allow-secrets -m --message --thread --system --system-file --system-append --personality --temperature --top-p --top-k --max-tokens --seed --stop --allow-tool --extra --no-stream -h --help" -- "$cur")) else - COMPREPLY=($(compgen -W "-m --message --thread --system --system-file --system-append --personality --temperature --top-p --top-k --max-tokens --seed --stop --allow-tool --extra --no-stream -h --help" -- "$cur")) + COMPREPLY=($(compgen -W "-p --project --allow-secrets -m --message --thread --system --system-file --system-append --personality --temperature --top-p --top-k --max-tokens --seed --stop --allow-tool --extra --no-stream -h --help" -- "$cur")) fi return ;; chat-llm) diff --git a/completions/mcpctl.fish b/completions/mcpctl.fish index 24b2b71..78caa5f 100644 --- a/completions/mcpctl.fish +++ b/completions/mcpctl.fish @@ -231,7 +231,7 @@ complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_ complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a create -d 'Create a resource (server, secret, secretbackend, llm, agent, project, user, group, rbac, serverattachment, prompt)' complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a edit -d 'Edit a resource in your default editor (server, project)' complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a apply -d 'Apply declarative configuration from a YAML or JSON file' -complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a chat -d 'Open an interactive chat session with an agent (REPL or one-shot).' +complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a chat -d 'Open an interactive chat session with an agent or a project (REPL or one-shot).' complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a chat-llm -d 'Stateless chat with any registered LLM (public or virtual). No threads, no tools.' complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a patch -d 'Patch a resource field (e.g. mcpctl patch project myproj llmProvider=none)' complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a passwd -d 'Change a user password (your own when called without an argument)' @@ -569,12 +569,14 @@ complete -c mcpctl -n "__fish_seen_subcommand_from apply" -s f -l file -d 'Path complete -c mcpctl -n "__fish_seen_subcommand_from apply" -l dry-run -d 'Validate and show changes without applying' # chat options +complete -c mcpctl -n "__fish_seen_subcommand_from chat" -s p -l project -d 'Chat scoped to a project: its prompts, tools, and (with --allow-secrets) secrets' -xa '(__mcpctl_project_names)' +complete -c mcpctl -n "__fish_seen_subcommand_from chat" -l allow-secrets -d 'Project chat: let the model read secret values via the get_secret tool (needs view:secrets)' complete -c mcpctl -n "__fish_seen_subcommand_from chat" -s m -l message -d 'One-shot: send a single message and exit (no REPL)' -x complete -c mcpctl -n "__fish_seen_subcommand_from chat" -l thread -d 'Resume an existing thread' -x -complete -c mcpctl -n "__fish_seen_subcommand_from chat" -l system -d 'Replace agent.systemPrompt for this session' -x +complete -c mcpctl -n "__fish_seen_subcommand_from chat" -l system -d 'Replace the base system prompt for this session' -x complete -c mcpctl -n "__fish_seen_subcommand_from chat" -l system-file -d 'Read --system text from a file' -x -complete -c mcpctl -n "__fish_seen_subcommand_from chat" -l system-append -d 'Append to the agent system block for this session' -x -complete -c mcpctl -n "__fish_seen_subcommand_from chat" -l personality -d 'Personality overlay (additive prompts on top of the agent)' -x +complete -c mcpctl -n "__fish_seen_subcommand_from chat" -l system-append -d 'Append to the system block for this session' -x +complete -c mcpctl -n "__fish_seen_subcommand_from chat" -l personality -d 'Personality overlay (agent chat only)' -x complete -c mcpctl -n "__fish_seen_subcommand_from chat" -l temperature -d 'Sampling temperature (0..2)' -x complete -c mcpctl -n "__fish_seen_subcommand_from chat" -l top-p -d 'Nucleus sampling cutoff (0..1)' -x complete -c mcpctl -n "__fish_seen_subcommand_from chat" -l top-k -d 'Top-K sampling (Anthropic; OpenAI ignores)' -x diff --git a/docs/chat.md b/docs/chat.md index 8134736..1ece80b 100644 --- a/docs/chat.md +++ b/docs/chat.md @@ -17,6 +17,49 @@ Streaming is on by default. Text deltas land on stdout as they arrive; tool calls and tool results print to stderr in dim brackets so the chat output stays clean. +## Project chat + +Chat directly with a **project** — no Agent needed. The session sees the +project's Prompts as system context, can call every tool from the project's +attached MCP servers, and uses the project's own LLM (`llmProvider`, with +`llmModel` as an optional served-model override). + +```bash +mcpctl chat --project sre # REPL scoped to project "sre" +mcpctl chat --project sre -m "what alerts fired overnight?" +mcpctl chat --project sre --thread # resume a past conversation +``` + +- **Prompts + tools** come from the project and stay current — anything you + add to the project later shows up automatically in the next turn. +- **LLM:** set it once with `mcpctl patch project sre llmProvider=` + (and optionally `llmModel=`). Chat errors clearly if unset. +- **History is saved inside the project**, attributed to you. List and resume: + ```bash + mcpctl get threads --project sre # your threads for this project + mcpctl chat --project sre --thread + ``` + Delete one you own (admins with `delete:projects` can delete anyone's): + ```bash + mcpctl delete thread --project sre + ``` + +### Reading secrets (`--allow-secrets`) + +By default the model cannot read secret values — it only benefits from them +indirectly (the project's tools run authenticated). Opt in with +`--allow-secrets` to expose a `get_secret` tool the model can call: + +```bash +mcpctl chat --project sre --allow-secrets +``` + +This is gated: it requires the flag **and** your `view:secrets` permission, +and it is off by default. Secret values the model reads land in the chat +context and thread history, so use it deliberately. + +RBAC: project chat and its threads route through `run:projects:`. + ## Per-call flags All optional. They override the agent's `defaultParams` for this session diff --git a/src/cli/src/commands/chat.ts b/src/cli/src/commands/chat.ts index f0772f1..3d04069 100644 --- a/src/cli/src/commands/chat.ts +++ b/src/cli/src/commands/chat.ts @@ -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 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 ', '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 ', 'One-shot: send a single message and exit (no REPL)') .option('--thread ', 'Resume an existing thread') - .option('--system ', 'Replace agent.systemPrompt for this session') + .option('--system ', 'Replace the base system prompt for this session') .option('--system-file ', 'Read --system text from a file') - .option('--system-append ', 'Append to the agent system block for this session') - .option('--personality ', 'Personality overlay (additive prompts on top of the agent)') + .option('--system-append ', 'Append to the system block for this session') + .option('--personality ', 'Personality overlay (agent chat only)') .option('--temperature ', 'Sampling temperature (0..2)', parseFloat) .option('--top-p ', 'Nucleus sampling cutoff (0..1)', parseFloat) .option('--top-k ', 'Top-K sampling (Anthropic; OpenAI ignores)', parseFloatInt) @@ -55,18 +57,61 @@ export function createChatCommand(deps: ChatCommandDeps): Command { .option('--allow-tool ', 'Restrict to this tool only (repeatable)', collect, []) .option('--extra ', '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 or --project , 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 name or --project . 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 { + const o: Record = { ...overrides }; + if (subject.kind === 'project') { + delete o.personality; + if (subject.allowSecrets) o.allowSecrets = true; + } + const body: Record = { 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 { - await printChatHeader(deps, agent, overrides); + await printChatHeader(deps, subject, overrides); if (stream === false) { - const body: Record = { 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 => 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 = { 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>( + `/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 to switch, /delete 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 (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 (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>( - `/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, ): 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 { - 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 { - let info: AgentInfo; - try { - info = await deps.client.get(`/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(`/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(`/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}` : ''; diff --git a/src/cli/src/commands/delete.ts b/src/cli/src/commands/delete.ts index 7814870..0c7c643 100644 --- a/src/cli/src/commands/delete.ts +++ b/src/cli/src/commands/delete.ts @@ -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 --project '); + } + await client.delete(`/api/v1/projects/${encodeURIComponent(opts.project)}/threads/${encodeURIComponent(idOrName)}`); + log(`thread '${idOrName}' deleted from project '${opts.project}'.`); + return; + } + // Serverattachments: delete serverattachment --project if (resource === 'serverattachments') { if (!opts.project) { diff --git a/src/cli/src/commands/get.ts b/src/cli/src/commands/get.ts index 5c1ca16..0014f95 100644 --- a/src/cli/src/commands/get.ts +++ b/src/cli/src/commands/get.ts @@ -234,6 +234,14 @@ const agentColumns: Column[] = [ { header: 'ID', key: 'id' }, ]; +interface ThreadRow { id: string; title: string; lastTurnAt: string; createdAt: string } +const threadColumns: Column[] = [ + { 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 return agentColumns as unknown as Column>[]; case 'personalities': return personalityColumns as unknown as Column>[]; + case 'threads': + return threadColumns as unknown as Column>[]; case 'inference-tasks': return inferenceTaskColumns as unknown as Column>[]; default: diff --git a/src/cli/src/index.ts b/src/cli/src/index.ts index de70732..935153f 100644 --- a/src/cli/src/index.ts +++ b/src/cli/src/index.ts @@ -131,6 +131,14 @@ export function createProgram(): Command { return client.get(`/api/v1/mcptokens?projectName=${encodeURIComponent(projectName)}`); } + // Chat threads are project-scoped history — `get threads --project `. + if (!nameOrId && resource === 'threads') { + if (!projectName) { + throw new Error('threads are scoped — pass --project (see `mcpctl chat --project`)'); + } + return client.get(`/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) { diff --git a/src/db/prisma/migrations/20260717234728_add_project_chat_threads/migration.sql b/src/db/prisma/migrations/20260717234728_add_project_chat_threads/migration.sql new file mode 100644 index 0000000..4637f02 --- /dev/null +++ b/src/db/prisma/migrations/20260717234728_add_project_chat_threads/migration.sql @@ -0,0 +1,17 @@ +-- Project-scoped chat threads: a ChatThread is now scoped to an Agent XOR a +-- Project. Existing rows are all agent-scoped, so dropping NOT NULL on agentId +-- is backward-compatible. + +-- Agent link becomes optional; add the project link. +ALTER TABLE "ChatThread" ALTER COLUMN "agentId" DROP NOT NULL; +ALTER TABLE "ChatThread" ADD COLUMN "projectId" TEXT; + +-- Exactly one of agentId / projectId must be set. +ALTER TABLE "ChatThread" ADD CONSTRAINT "ChatThread_agent_xor_project" + CHECK ((("agentId" IS NOT NULL)::int + ("projectId" IS NOT NULL)::int) = 1); + +-- Project FK (cascade delete threads with their project, mirroring the agent FK). +ALTER TABLE "ChatThread" ADD CONSTRAINT "ChatThread_projectId_fkey" + FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +CREATE INDEX "ChatThread_projectId_lastTurnAt_idx" ON "ChatThread"("projectId", "lastTurnAt" DESC); diff --git a/src/db/prisma/schema.prisma b/src/db/prisma/schema.prisma index 9d7f1f3..aae073d 100644 --- a/src/db/prisma/schema.prisma +++ b/src/db/prisma/schema.prisma @@ -336,6 +336,7 @@ model Project { skills Skill[] mcpTokens McpToken[] agents Agent[] + chatThreads ChatThread[] @@index([name]) @@index([ownerId]) @@ -751,18 +752,24 @@ model PersonalityPrompt { model ChatThread { id String @id @default(cuid()) - agentId String + // A thread is scoped to an Agent XOR a Project (enforced by a CHECK + // constraint in the migration + the service layer). Project-scoped threads + // back `mcpctl chat --project `. + agentId String? + projectId String? ownerId String title String @default("") lastTurnAt DateTime @default(now()) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - agent Agent @relation(fields: [agentId], references: [id], onDelete: Cascade) + agent Agent? @relation(fields: [agentId], references: [id], onDelete: Cascade) + project Project? @relation(fields: [projectId], references: [id], onDelete: Cascade) owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade) messages ChatMessage[] @@index([agentId, lastTurnAt(sort: Desc)]) + @@index([projectId, lastTurnAt(sort: Desc)]) @@index([ownerId]) } diff --git a/src/mcpd/src/main.ts b/src/mcpd/src/main.ts index cc0acdd..02a92a5 100644 --- a/src/mcpd/src/main.ts +++ b/src/mcpd/src/main.ts @@ -47,6 +47,7 @@ import { registerInferenceTaskRoutes } from './routes/inference-tasks.js'; import { VirtualLlmService } from './services/virtual-llm.service.js'; import { registerAgentRoutes } from './routes/agents.js'; import { registerAgentChatRoutes } from './routes/agent-chat.js'; +import { registerProjectChatRoutes } from './routes/project-chat.js'; import { PromptRepository } from './repositories/prompt.repository.js'; import { PromptRequestRepository } from './repositories/prompt-request.repository.js'; import { PersonalityRepository } from './repositories/personality.repository.js'; @@ -233,6 +234,14 @@ function mapUrlToPermission(method: string, url: string): PermissionCheck { return { kind: 'resource', resource: 'mcptokens', action: 'delete', resourceName: revokeMatch[1] }; } + // Special case: /api/v1/projects/:name/(chat|threads) → run:projects:. + // Driving a project chat turn or managing its threads is a "run" on the + // project; per-thread ownership + admin-delete are enforced in the service. + const projectRunMatch = url.match(/^\/api\/v1\/projects\/([^/?]+)\/(chat|threads)/); + if (projectRunMatch?.[1]) { + return { kind: 'resource', resource: 'projects', action: 'run', resourceName: projectRunMatch[1] }; + } + // Special case: /api/v1/projects/:name/prompts/visible → view prompts const visiblePromptsMatch = url.match(/^\/api\/v1\/projects\/([^/?]+)\/prompts\/visible/); if (visiblePromptsMatch?.[1]) { @@ -683,8 +692,12 @@ async function main(): Promise { chatToolDispatcher, personalityRepo, virtualLlmService, + projectRepo, + rbacService, + secretService, ); registerAgentChatRoutes(app, chatService); + registerProjectChatRoutes(app, chatService, rbacService); registerLlmInferRoutes(app, { llmService, adapters: llmAdapters, diff --git a/src/mcpd/src/repositories/chat.repository.ts b/src/mcpd/src/repositories/chat.repository.ts index 8d004b6..c85df06 100644 --- a/src/mcpd/src/repositories/chat.repository.ts +++ b/src/mcpd/src/repositories/chat.repository.ts @@ -27,10 +27,20 @@ export interface AppendMessageInput { turnIndex?: number; } +/** A thread is scoped to an Agent XOR a Project (exactly one id set). */ +export interface CreateThreadInput { + agentId?: string; + projectId?: string; + ownerId: string; + title?: string; +} + export interface IChatRepository { - createThread(input: { agentId: string; ownerId: string; title?: string }): Promise; + createThread(input: CreateThreadInput): Promise; findThread(id: string): Promise; listThreadsByAgent(agentId: string, ownerId?: string): Promise; + listThreadsByProject(projectId: string, ownerId?: string): Promise; + deleteThread(id: string): Promise; appendMessage(input: AppendMessageInput): Promise; listMessages(threadId: string): Promise; updateStatus(messageId: string, status: ChatStatus): Promise; @@ -47,10 +57,11 @@ const UNIQUE_VIOLATION = 'P2002'; export class ChatRepository implements IChatRepository { constructor(private readonly prisma: PrismaClient) {} - async createThread(input: { agentId: string; ownerId: string; title?: string }): Promise { + async createThread(input: CreateThreadInput): Promise { return this.prisma.chatThread.create({ data: { - agentId: input.agentId, + agentId: input.agentId ?? null, + projectId: input.projectId ?? null, ownerId: input.ownerId, title: input.title ?? '', }, @@ -68,6 +79,18 @@ export class ChatRepository implements IChatRepository { }); } + async listThreadsByProject(projectId: string, ownerId?: string): Promise { + return this.prisma.chatThread.findMany({ + where: { projectId, ...(ownerId !== undefined ? { ownerId } : {}) }, + orderBy: { lastTurnAt: 'desc' }, + }); + } + + async deleteThread(id: string): Promise { + // Messages cascade via the ChatMessage.thread FK (onDelete: Cascade). + await this.prisma.chatThread.delete({ where: { id } }); + } + async listMessages(threadId: string): Promise { return this.prisma.chatMessage.findMany({ where: { threadId }, diff --git a/src/mcpd/src/routes/project-chat.ts b/src/mcpd/src/routes/project-chat.ts new file mode 100644 index 0000000..1aef374 --- /dev/null +++ b/src/mcpd/src/routes/project-chat.ts @@ -0,0 +1,150 @@ +/** + * Project-scoped chat + threads HTTP surface. Mirrors `agent-chat.ts`, but the + * chat subject is a Project (its Prompts → system, its MCP servers → tools, + * its llmProvider/llmModel → the LLM), and history is persisted as + * project-scoped ChatThreads attributed to the caller. + * + * POST /api/v1/projects/:name/chat — chat (non-streaming + SSE) + * POST /api/v1/projects/:name/threads — explicit thread create + * GET /api/v1/projects/:name/threads — list threads (caller-scoped) + * DELETE /api/v1/projects/:name/threads/:id — delete a thread (owner or admin) + * + * RBAC: chat/threads route through `run:projects:` (see main.ts + * mapUrlToPermission). Thread reads/deletes add a service-level owner check; + * delete additionally honors `delete:projects:` as an admin override. + * The SSE frame format matches agent-chat / llm-infer exactly. + */ +import type { FastifyInstance, FastifyReply } from 'fastify'; +import type { ChatService, ChatStreamChunk } from '../services/chat.service.js'; +import type { RbacService } from '../services/rbac.service.js'; +import { ProjectChatRequestSchema } from '../validation/agent.schema.js'; +import { NotFoundError } from '../services/mcp-server.service.js'; + +export function registerProjectChatRoutes( + app: FastifyInstance, + chat: ChatService, + rbac: RbacService, +): void { + app.post<{ Params: { name: string } }>( + '/api/v1/projects/:name/chat', + async (request, reply) => { + const ownerId = request.userId ?? 'system'; + let parsed; + try { + parsed = ProjectChatRequestSchema.parse(request.body ?? {}); + } catch (err) { + reply.code(400); + return { error: (err as Error).message }; + } + + const { threadId, message, messages: messagesOverride, stream, allowSecrets, ...paramsRest } = parsed; + + const args = { + projectName: request.params.name, + ownerId, + ...(threadId !== undefined ? { threadId } : {}), + ...(message !== undefined ? { userMessage: message } : {}), + ...(messagesOverride !== undefined + ? { messagesOverride: messagesOverride.map((m) => ({ role: m.role, content: m.content, ...(m.tool_call_id !== undefined ? { tool_call_id: m.tool_call_id } : {}) })) } + : {}), + ...(allowSecrets !== undefined ? { allowSecrets } : {}), + params: paramsRest, + }; + + if (stream !== true) { + try { + return await chat.chatProject(args); + } catch (err) { + if (err instanceof NotFoundError) { + reply.code(404); + return { error: err.message }; + } + reply.code(502); + return { error: (err as Error).message }; + } + } + + reply.raw.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + 'X-Accel-Buffering': 'no', + }); + try { + for await (const chunk of chat.chatProjectStream(args)) { + writeSseChunk(reply, JSON.stringify(chunk)); + if (chunk.type === 'final' || chunk.type === 'error') break; + } + writeSseChunk(reply, '[DONE]'); + } catch (err) { + const payload: ChatStreamChunk = { type: 'error', message: (err as Error).message }; + writeSseChunk(reply, JSON.stringify(payload)); + writeSseChunk(reply, '[DONE]'); + } finally { + reply.raw.end(); + } + return reply; + }, + ); + + app.post<{ Params: { name: string }; Body: { title?: string } }>( + '/api/v1/projects/:name/threads', + async (request, reply) => { + const ownerId = request.userId ?? 'system'; + try { + const t = await chat.createProjectThread(request.params.name, ownerId, request.body?.title); + reply.code(201); + return t; + } catch (err) { + if (err instanceof NotFoundError) { + reply.code(404); + return { error: err.message }; + } + throw err; + } + }, + ); + + app.get<{ Params: { name: string } }>( + '/api/v1/projects/:name/threads', + async (request, reply) => { + const ownerId = request.userId ?? undefined; + try { + return await chat.listProjectThreads(request.params.name, ownerId); + } catch (err) { + if (err instanceof NotFoundError) { + reply.code(404); + return { error: err.message }; + } + throw err; + } + }, + ); + + app.delete<{ Params: { name: string; id: string } }>( + '/api/v1/projects/:name/threads/:id', + async (request, reply) => { + const ownerId = request.userId ?? 'system'; + // Admin override: a caller who can delete the project can prune anyone's + // threads; otherwise only their own (enforced in the service, 404 on miss). + const canDeleteOthers = request.userId !== undefined + && await rbac.canAccess(request.userId, 'delete', 'projects', request.params.name); + try { + await chat.deleteThread(request.params.id, ownerId, canDeleteOthers); + reply.code(204); + return null; + } catch (err) { + if (err instanceof NotFoundError) { + reply.code(404); + return { error: err.message }; + } + throw err; + } + }, + ); +} + +function writeSseChunk(reply: FastifyReply, data: string): void { + if (reply.raw.destroyed || reply.raw.writableEnded) return; + reply.raw.write(`data: ${data}\n\n`); +} diff --git a/src/mcpd/src/services/chat.service.ts b/src/mcpd/src/services/chat.service.ts index d18b1b1..65556d0 100644 --- a/src/mcpd/src/services/chat.service.ts +++ b/src/mcpd/src/services/chat.service.ts @@ -32,11 +32,22 @@ import type { } from '../repositories/chat.repository.js'; import type { IPromptRepository } from '../repositories/prompt.repository.js'; import type { IPersonalityRepository } from '../repositories/personality.repository.js'; +import type { IProjectRepository } from '../repositories/project.repository.js'; import type { IVirtualLlmService } from './virtual-llm.service.js'; +import type { RbacService } from './rbac.service.js'; import type { OpenAiChatRequest, OpenAiMessage } from './llm/types.js'; import type { AgentChatParams } from '../validation/agent.schema.js'; import { NotFoundError } from './mcp-server.service.js'; +/** Minimal secret-read surface the project-chat `get_secret` tool needs. */ +export interface ChatSecretResolver { + resolve(secretName: string, key: string): Promise; +} + +/** Wire name of the gated virtual secret-read tool offered in project chat. */ +export const SECRET_TOOL_SERVER = 'secrets'; +export const SECRET_TOOL_NAME = 'get_secret'; + export const TOOL_NAME_SEPARATOR = '__'; /** Default tool-loop cap. Per-agent override via `agent.extras.maxIterations`, clamped to MIN..MAX. */ export const MAX_ITERATIONS = 12; @@ -142,6 +153,36 @@ export interface ChatResult { turnIndex: number; } +/** + * Project-scoped chat: `mcpctl chat --project `. Same turn loop as an + * agent, but the LLM/system-block/tools are resolved from the Project instead + * of a named Agent. `allowSecrets` gates the virtual `get_secret` tool. + */ +export interface ChatProjectRequestArgs { + projectName: string; + threadId?: string; + userMessage?: string; + messagesOverride?: OpenAiMessage[]; + ownerId: string; + params?: AgentChatParams; + allowSecrets?: boolean; +} + +/** The fully-resolved per-turn context both the agent and project paths share. */ +interface PreparedChatContext { + threadId: string; + history: OpenAiMessage[]; + systemBlock: string; + poolCandidates: PoolCandidate[]; + mergedParams: AgentChatParams; + toolList: ChatTool[]; + projectId: string | null; + startingTurnIndex: number; + maxIterations: number; + /** When true, `dispatchTool` will honor the virtual `secrets__get_secret` call. */ + allowSecrets: boolean; +} + export class ChatService { constructor( private readonly agents: AgentService, @@ -159,6 +200,10 @@ export class ChatService { * with a clear error. */ private readonly virtualLlms?: IVirtualLlmService, + /** Project-chat deps (optional so agent-only test wirings still compile). */ + private readonly projects?: IProjectRepository, + private readonly rbac?: RbacService, + private readonly secrets?: ChatSecretResolver, ) {} async createThread(agentName: string, ownerId: string, title?: string): Promise<{ id: string }> { @@ -190,9 +235,18 @@ export class ChatService { return this.chatRepo.listMessages(threadId); } - /** Non-streaming chat. Persists rows + returns the final assistant text. */ + /** Non-streaming chat with an agent. */ async chat(args: ChatRequestArgs): Promise { - const ctx = await this.prepareContext(args); + return this.runChatLoop(await this.prepareContext(args)); + } + + /** Non-streaming chat scoped to a project. */ + async chatProject(args: ChatProjectRequestArgs): Promise { + return this.runChatLoop(await this.prepareProjectContext(args)); + } + + /** Shared non-streaming turn loop. Persists rows + returns the final text. */ + private async runChatLoop(ctx: PreparedChatContext): Promise { let assistantFinal = ''; let lastTurnIndex = ctx.startingTurnIndex; try { @@ -224,7 +278,7 @@ export class ChatService { tool_calls: choice.tool_calls, }); for (const call of choice.tool_calls) { - const toolResult = await this.dispatchTool(call.function.name, call.function.arguments, ctx.projectId); + const toolResult = await this.dispatchTool(call.function.name, call.function.arguments, ctx.projectId, ctx.allowSecrets); const resultMsg = await this.chatRepo.appendMessage({ threadId: ctx.threadId, role: 'tool', @@ -263,9 +317,18 @@ export class ChatService { } } - /** Streaming chat. Yields text deltas + tool events. Persists rows in lockstep. */ + /** Streaming chat with an agent. Yields text deltas + tool events. */ async *chatStream(args: ChatRequestArgs): AsyncGenerator { - const ctx = await this.prepareContext(args); + yield* this.runChatStreamLoop(await this.prepareContext(args)); + } + + /** Streaming chat scoped to a project. */ + async *chatProjectStream(args: ChatProjectRequestArgs): AsyncGenerator { + yield* this.runChatStreamLoop(await this.prepareProjectContext(args)); + } + + /** Shared streaming turn loop. Persists rows in lockstep. */ + private async *runChatStreamLoop(ctx: PreparedChatContext): AsyncGenerator { try { for (let i = 0; i < ctx.maxIterations; i += 1) { // `reasoning` is accumulated alongside `content` so we can fall back @@ -335,7 +398,7 @@ export class ChatService { for (const call of accumulated.toolCalls) { yield { type: 'tool_call', toolName: call.name, args: safeParseJson(call.argumentsJson) as Record }; try { - const result = await this.dispatchTool(call.name, call.argumentsJson, ctx.projectId); + const result = await this.dispatchTool(call.name, call.argumentsJson, ctx.projectId, ctx.allowSecrets); const resultStr = typeof result === 'string' ? result : JSON.stringify(result); await this.chatRepo.appendMessage({ threadId: ctx.threadId, @@ -564,24 +627,7 @@ export class ChatService { }); } - private async prepareContext(args: ChatRequestArgs): Promise<{ - threadId: string; - history: OpenAiMessage[]; - systemBlock: string; - /** - * v4: ordered pool members for this turn. [0] is the candidate to try - * first (random shuffle of viable members so load spreads). On - * transport-level failure the dispatcher iterates the rest. Always - * non-empty — at minimum the agent's pinned Llm (or a sibling pool - * member when the pinned row is itself inactive but others are up). - */ - poolCandidates: PoolCandidate[]; - mergedParams: AgentChatParams; - toolList: ChatTool[]; - projectId: string | null; - startingTurnIndex: number; - maxIterations: number; - }> { + private async prepareContext(args: ChatRequestArgs): Promise { const agent = await this.agents.getByName(args.agentName); const pinned = await this.llms.getByName(agent.llm.name); const poolCandidates = await this.resolvePoolCandidates(pinned); @@ -668,9 +714,141 @@ export class ChatService { projectId, startingTurnIndex, maxIterations: resolveMaxIterations(agent.extras), + allowSecrets: false, }; } + // ── Project-scoped chat ────────────────────────────────────────────────── + + /** Create a new project-scoped thread (owned by the caller). */ + async createProjectThread(projectName: string, ownerId: string, title?: string): Promise<{ id: string }> { + const project = await this.requireProject(projectName); + const thread = await this.chatRepo.createThread({ + projectId: project.id, + ownerId, + ...(title !== undefined ? { title } : {}), + }); + return { id: thread.id }; + } + + /** List the caller's threads for a project (or all, when ownerId omitted). */ + async listProjectThreads(projectName: string, ownerId?: string): Promise> { + const project = await this.requireProject(projectName); + const rows = await this.chatRepo.listThreadsByProject(project.id, ownerId); + return rows.map((r) => ({ id: r.id, title: r.title, lastTurnAt: r.lastTurnAt, createdAt: r.createdAt })); + } + + /** + * Delete a thread. Allowed when the caller owns it, or holds admin override + * (`canDeleteOthers`, wired to `delete:projects:` at the route). 404 + * (not 403) on a foreign thread the caller can't override — no id-enumeration. + */ + async deleteThread(threadId: string, ownerId: string, canDeleteOthers = false): Promise { + const thread = await this.chatRepo.findThread(threadId); + if (thread === null) throw new NotFoundError(`Thread not found: ${threadId}`); + if (thread.ownerId !== ownerId && !canDeleteOthers) { + throw new NotFoundError(`Thread not found: ${threadId}`); + } + await this.chatRepo.deleteThread(threadId); + } + + private async requireProject(projectName: string): Promise<{ id: string; name: string; prompt: string; llmProvider: string | null; llmModel: string | null }> { + if (this.projects === undefined) { + throw new Error('project chat is not wired into ChatService (no project repository)'); + } + const project = await this.projects.findByName(projectName); + if (project === null) throw new NotFoundError(`Project not found: ${projectName}`); + return project; + } + + private async prepareProjectContext(args: ChatProjectRequestArgs): Promise { + const project = await this.requireProject(args.projectName); + if (project.llmProvider === null || project.llmProvider === '') { + throw new NotFoundError( + `Project '${project.name}' has no llmProvider set. Set one with 'mcpctl edit project ${project.name}' (llmProvider = an Llm name).`, + ); + } + const pinned = await this.llms.getByName(project.llmProvider); + // project.llmModel overrides the pinned Llm's own model for this chat. + const poolCandidates = await this.resolvePoolCandidates(pinned, project.llmModel ?? undefined); + if (poolCandidates.length === 0) { + throw new NotFoundError( + `No active Llm in pool '${effectivePoolName(pinned)}' (project '${project.name}' → ${pinned.name})`, + ); + } + + const threadId = await this.resolveProjectThreadId(args, project.id); + const projectId = project.id; + + // System block: the project's own prompt text first, then its Prompts by + // priority desc (reusing the agent-path filter). No agent/personality. + const projectPrompts = (await this.promptRepo.findAll(projectId)) + .filter((p) => p.projectId === projectId) + .sort((a, b) => b.priority - a.priority); + + const mergedParams: AgentChatParams = { ...(args.params ?? {}) }; + const baseSystem = mergedParams.systemOverride ?? project.prompt; + const systemBlock = [ + baseSystem, + ...projectPrompts.map((p) => p.content), + mergedParams.systemAppend ?? '', + ] + .filter((s) => s.length > 0) + .join('\n\n'); + + const history = args.messagesOverride !== undefined + ? [...args.messagesOverride] + : await this.loadHistory(threadId); + + let startingTurnIndex = await this.chatRepo.nextTurnIndex(threadId); + if (args.userMessage !== undefined && args.messagesOverride === undefined) { + const userTurn = await this.chatRepo.appendMessage({ threadId, role: 'user', content: args.userMessage }); + startingTurnIndex = userTurn.turnIndex; + history.push({ role: 'user', content: args.userMessage }); + } + + const baseTools = await this.tools.listTools(projectId); + // Gated secret-read tool: only offered when the caller asked (--allow-secrets) + // AND holds view:secrets. Never exposed by default. + const allowSecrets = await this.resolveAllowSecrets(args); + const toolList = allowSecrets ? [...baseTools, secretReadTool()] : baseTools; + const allowed = mergedParams.tools_allowlist; + const filteredTools = allowed === undefined ? toolList : toolList.filter((t) => allowed.includes(t.name)); + + return { + threadId, + history, + systemBlock, + poolCandidates, + mergedParams, + toolList: filteredTools, + projectId, + startingTurnIndex, + maxIterations: MAX_ITERATIONS, + allowSecrets, + }; + } + + /** Gate the get_secret tool: requires the flag AND the caller's view:secrets. */ + private async resolveAllowSecrets(args: ChatProjectRequestArgs): Promise { + if (args.allowSecrets !== true) return false; + if (this.secrets === undefined) return false; + if (this.rbac === undefined) return true; // no RBAC wired (tests) → trust the flag + return this.rbac.canAccess(args.ownerId, 'view', 'secrets'); + } + + private async resolveProjectThreadId(args: ChatProjectRequestArgs, projectId: string): Promise { + if (args.threadId !== undefined) { + const existing = await this.chatRepo.findThread(args.threadId); + if (existing === null) throw new NotFoundError(`Thread not found: ${args.threadId}`); + // Owner check on resume (mirrors listMessages): a foreign thread id 404s. + if (existing.ownerId !== args.ownerId) throw new NotFoundError(`Thread not found: ${args.threadId}`); + return existing.id; + } + const created = await this.chatRepo.createThread({ projectId, ownerId: args.ownerId }); + return created.id; + } + /** * v4: resolve the load-balanced pool for an agent's pinned Llm. The * pool is "all Llms whose effective key (poolName ?? name) matches the @@ -684,7 +862,10 @@ export class ChatService { * doesn't get priority — if it happens to be down, a sibling takes * the call without the caller noticing. */ - private async resolvePoolCandidates(pinned: { name: string; poolName: string | null }): Promise { + private async resolvePoolCandidates( + pinned: { name: string; poolName: string | null }, + modelOverride?: string, + ): Promise { const poolKey = effectivePoolName(pinned); const rows = await this.llms.findByPoolName(poolKey); const viable = rows.filter((r) => r.status !== 'inactive'); @@ -697,7 +878,9 @@ export class ChatService { llmName: r.name, llmType: r.type, llmKind: r.kind as 'public' | 'virtual', - modelOverride: r.model, + // A project can pin all its pool members to one served model via + // project.llmModel; otherwise each row uses its own configured model. + modelOverride: modelOverride ?? r.model, url: r.url, apiKey, extraConfig: r.extraConfig as Record, @@ -809,10 +992,23 @@ export class ChatService { return body; } - private async dispatchTool(toolWireName: string, argsJson: string, projectId: string | null): Promise { + private async dispatchTool(toolWireName: string, argsJson: string, projectId: string | null, allowSecrets: boolean): Promise { if (projectId === null) { throw new Error('Tool calls require an agent attached to a Project'); } + // Gated virtual tool: reading a secret value. Only honored when the chat + // was started with secret access AND the resolver is wired — never routes + // to an MCP server. A hallucinated call without the gate fails loudly. + if (toolWireName === `${SECRET_TOOL_SERVER}${TOOL_NAME_SEPARATOR}${SECRET_TOOL_NAME}`) { + if (!allowSecrets || this.secrets === undefined) { + throw new Error('secret access is not enabled for this chat'); + } + const parsed = safeParseJson(argsJson) as { name?: unknown; key?: unknown }; + if (typeof parsed.name !== 'string' || typeof parsed.key !== 'string') { + throw new Error('get_secret requires string arguments { name, key }'); + } + return this.secrets.resolve(parsed.name, parsed.key); + } const sep = toolWireName.indexOf(TOOL_NAME_SEPARATOR); if (sep === -1) { throw new Error(`Tool name '${toolWireName}' missing '${TOOL_NAME_SEPARATOR}' separator`); @@ -899,6 +1095,25 @@ function safeParseJson(s: string): unknown { } } +/** The gated virtual secret-read tool offered to project chat with --allow-secrets. */ +function secretReadTool(): ChatTool { + return { + name: `${SECRET_TOOL_SERVER}${TOOL_NAME_SEPARATOR}${SECRET_TOOL_NAME}`, + description: + 'Read the value of one key inside a named mcpctl Secret. Use ONLY when you ' + + 'genuinely need a credential or config value to complete the task. Returns ' + + 'the raw secret value as text.', + parameters: { + type: 'object', + properties: { + name: { type: 'string', description: 'The Secret resource name (see `mcpctl get secrets`).' }, + key: { type: 'string', description: "The key inside that secret's data map." }, + }, + required: ['name', 'key'], + }, + }; +} + interface ParsedStreamEvent { contentDelta?: string; /** diff --git a/src/mcpd/src/validation/agent.schema.ts b/src/mcpd/src/validation/agent.schema.ts index 2710598..cdfb215 100644 --- a/src/mcpd/src/validation/agent.schema.ts +++ b/src/mcpd/src/validation/agent.schema.ts @@ -125,6 +125,33 @@ export const AgentChatRequestSchema = AgentChatParamsSchema.merge( message: 'Either `message` or `messages` is required', }); +/** + * Project-scoped chat request (`POST /api/v1/projects/:name/chat`). Same + * LiteLLM-style params as an agent chat, minus the agent-only `personality` + * overlay, plus `allowSecrets` to opt into the gated get_secret tool. + */ +export const ProjectChatRequestSchema = AgentChatParamsSchema.merge( + z.object({ + threadId: z.string().min(1).optional(), + message: z.string().min(1).optional(), + messages: z + .array( + z.object({ + role: z.enum(['system', 'user', 'assistant', 'tool']), + content: z.string(), + tool_call_id: z.string().optional(), + }), + ) + .optional(), + stream: z.boolean().optional(), + /** Opt into the gated get_secret tool (still requires the caller's view:secrets). */ + allowSecrets: z.boolean().optional(), + }), +).strict().refine((v) => v.message !== undefined || (v.messages?.length ?? 0) > 0, { + message: 'Either `message` or `messages` is required', +}); + export type CreateAgentInput = z.infer; export type UpdateAgentInput = z.infer; export type AgentChatRequest = z.infer; +export type ProjectChatRequest = z.infer; diff --git a/src/mcpd/tests/chat-service-project.test.ts b/src/mcpd/tests/chat-service-project.test.ts new file mode 100644 index 0000000..886a0e5 --- /dev/null +++ b/src/mcpd/tests/chat-service-project.test.ts @@ -0,0 +1,232 @@ +import { describe, it, expect, vi } from 'vitest'; +import { ChatService, TOOL_NAME_SEPARATOR, type ChatToolDispatcher, type ChatSecretResolver } from '../src/services/chat.service.js'; +import type { AgentService } from '../src/services/agent.service.js'; +import type { LlmService } from '../src/services/llm.service.js'; +import type { LlmAdapterRegistry } from '../src/services/llm/dispatcher.js'; +import type { LlmAdapter, NonStreamingResult, InferContext } from '../src/services/llm/types.js'; +import type { IChatRepository } from '../src/repositories/chat.repository.js'; +import type { IPromptRepository } from '../src/repositories/prompt.repository.js'; +import type { IProjectRepository } from '../src/repositories/project.repository.js'; +import type { RbacService } from '../src/services/rbac.service.js'; +import type { ChatMessage, ChatThread, Prompt } from '@prisma/client'; + +const NOW = new Date(); + +function mockChatRepo(): IChatRepository & { _msgs: ChatMessage[]; _threads: ChatThread[] } { + const msgs: ChatMessage[] = []; + const threads: ChatThread[] = []; + let id = 1; + return { + _msgs: msgs, + _threads: threads, + createThread: vi.fn(async ({ agentId, projectId, ownerId, title }) => { + const t = { + id: `thread-${String(id++)}`, + agentId: agentId ?? null, + projectId: projectId ?? null, + ownerId, + title: title ?? '', + lastTurnAt: NOW, createdAt: NOW, updatedAt: NOW, + } as ChatThread; + threads.push(t); + return t; + }), + findThread: vi.fn(async (tid: string) => threads.find((t) => t.id === tid) ?? null), + listThreadsByAgent: vi.fn(async () => []), + listThreadsByProject: vi.fn(async (projectId: string, ownerId?: string) => + threads.filter((t) => t.projectId === projectId && (ownerId === undefined || t.ownerId === ownerId))), + deleteThread: vi.fn(async (tid: string) => { const i = threads.findIndex((t) => t.id === tid); if (i >= 0) threads.splice(i, 1); }), + listMessages: vi.fn(async (tid: string) => msgs.filter((m) => m.threadId === tid).sort((a, b) => a.turnIndex - b.turnIndex)), + appendMessage: vi.fn(async (input) => { + const m = { + id: `msg-${String(id++)}`, threadId: input.threadId, + turnIndex: input.turnIndex ?? msgs.filter((x) => x.threadId === input.threadId).length, + role: input.role, content: input.content, + toolCalls: (input.toolCalls ?? null) as ChatMessage['toolCalls'], + toolCallId: input.toolCallId ?? null, status: input.status ?? 'complete', createdAt: NOW, + } as ChatMessage; + msgs.push(m); + return m; + }), + updateStatus: vi.fn(async (mid: string, status) => { const m = msgs.find((x) => x.id === mid)!; m.status = status; return m; }), + markPendingAsError: vi.fn(async () => 0), + touchThread: vi.fn(async () => undefined), + nextTurnIndex: vi.fn(async (tid: string) => msgs.filter((m) => m.threadId === tid).length), + }; +} + +function mockPromptRepo(rows: Prompt[] = []): IPromptRepository { + return { + findAll: vi.fn(async () => rows), + findGlobal: vi.fn(async () => []), + findByAgent: vi.fn(async () => []), + findById: vi.fn(async () => null), + findByNameAndProject: vi.fn(async () => null), + findByNameAndAgent: vi.fn(async () => null), + create: vi.fn(), update: vi.fn(), delete: vi.fn(), + } as unknown as IPromptRepository; +} + +function prompt(name: string, content: string, priority: number): Prompt { + return { id: name, name, content, priority, projectId: 'proj-1', agentId: null, version: 1, createdAt: NOW, updatedAt: NOW } as unknown as Prompt; +} + +function mockProjects(over: Partial<{ llmProvider: string | null; llmModel: string | null; prompt: string }> = {}): IProjectRepository { + return { + findByName: vi.fn(async (name: string) => ({ + id: 'proj-1', name, + description: 'the project', prompt: over.prompt ?? 'Project system prompt.', + llmProvider: over.llmProvider === undefined ? 'qwen3-thinking' : over.llmProvider, + llmModel: over.llmModel ?? null, + proxyModel: '', gated: true, serverOverrides: null, ownerId: 'owner-1', version: 1, createdAt: NOW, updatedAt: NOW, + servers: [], + })), + findById: vi.fn(async () => null), findAll: vi.fn(async () => []), + create: vi.fn(), update: vi.fn(), delete: vi.fn(), setServers: vi.fn(), addServer: vi.fn(), removeServer: vi.fn(), + } as unknown as IProjectRepository; +} + +function mockLlms(): LlmService { + const row = (name: string): Record => ({ + id: 'llm-1', name, type: 'openai', model: 'served-model', url: '', tier: 'fast', description: '', + apiKeySecretId: null, apiKeySecretKey: null, extraConfig: {}, poolName: null, kind: 'public', + providerSessionId: null, status: 'active', lastHeartbeatAt: null, inactiveSince: null, version: 1, createdAt: NOW, updatedAt: NOW, + }); + return { + getByName: vi.fn(async (name: string) => ({ ...row(name), apiKeyRef: null })), + findByPoolName: vi.fn(async (poolName: string) => [row(poolName)]), + resolveApiKey: vi.fn(async () => 'k'), + } as unknown as LlmService; +} + +function mockTools(impl: Partial = {}): ChatToolDispatcher { + return { listTools: impl.listTools ?? vi.fn(async () => []), callTool: impl.callTool ?? vi.fn(async () => ({ ok: true })) }; +} + +function adapterRegistry(adapter: LlmAdapter): LlmAdapterRegistry { + return { get: () => adapter } as unknown as LlmAdapterRegistry; +} + +function scriptedAdapter(responses: NonStreamingResult[]): LlmAdapter { + let i = 0; + return { + kind: 'scripted', + infer: vi.fn(async (_ctx: InferContext) => responses[i++] ?? responses[responses.length - 1]!), + stream: async function*() { yield { data: '[DONE]', done: true }; }, + }; +} + +function text(content: string): NonStreamingResult { + return { status: 200, body: { id: 'c', object: 'chat.completion', choices: [{ index: 0, message: { role: 'assistant', content }, finish_reason: 'stop' }] } }; +} +function toolCall(name: string, args: Record): NonStreamingResult { + return { status: 200, body: { id: 'c', object: 'chat.completion', choices: [{ index: 0, message: { role: 'assistant', content: '', tool_calls: [{ id: `call-${name}`, type: 'function', function: { name, arguments: JSON.stringify(args) } }] }, finish_reason: 'tool_calls' }] } }; +} + +function svc(opts: { + chatRepo?: ReturnType; + prompts?: Prompt[]; + projects?: IProjectRepository; + tools?: ChatToolDispatcher; + adapter?: LlmAdapter; + rbac?: RbacService; + secrets?: ChatSecretResolver; +} = {}): { service: ChatService; chatRepo: ReturnType; adapter: LlmAdapter } { + const chatRepo = opts.chatRepo ?? mockChatRepo(); + const adapter = opts.adapter ?? scriptedAdapter([text('project reply')]); + const service = new ChatService( + {} as AgentService, mockLlms(), adapterRegistry(adapter), + chatRepo, mockPromptRepo(opts.prompts ?? []), opts.tools ?? mockTools(), + undefined, undefined, + opts.projects ?? mockProjects(), opts.rbac, opts.secrets, + ); + return { service, chatRepo, adapter }; +} + +describe('ChatService — project chat', () => { + it('assembles system block from project.prompt + project prompts (priority desc) and replies', async () => { + const { service, chatRepo, adapter } = svc({ + prompts: [prompt('low', 'LOW', 1), prompt('high', 'HIGH', 9)], + }); + const res = await service.chatProject({ projectName: 'sre', userMessage: 'hi', ownerId: 'owner-1' }); + expect(res.assistant).toBe('project reply'); + // thread is project-scoped, attributed to the owner + const t = chatRepo._threads[0]!; + expect(t.projectId).toBe('proj-1'); + expect(t.agentId).toBeNull(); + expect(t.ownerId).toBe('owner-1'); + // system message = project.prompt then HIGH then LOW + const sys = (adapter.infer as ReturnType).mock.calls[0][0].body.messages[0]; + expect(sys.role).toBe('system'); + expect(sys.content).toBe('Project system prompt.\n\nHIGH\n\nLOW'); + }); + + it('routes tool calls to the project dispatcher with projectId', async () => { + const callTool = vi.fn(async () => ({ content: [{ type: 'text', text: 'tool ok' }] })); + const { service } = svc({ + tools: mockTools({ listTools: vi.fn(async () => [{ name: `srv${TOOL_NAME_SEPARATOR}do`, description: 'd', parameters: {} }]), callTool }), + adapter: scriptedAdapter([toolCall('srv__do', { x: 1 }), text('done')]), + }); + await service.chatProject({ projectName: 'sre', userMessage: 'go', ownerId: 'owner-1' }); + expect(callTool).toHaveBeenCalledWith({ projectId: 'proj-1', serverName: 'srv', toolName: 'do', args: { x: 1 } }); + }); + + it('does NOT offer get_secret without --allow-secrets', async () => { + const { service, adapter } = svc(); + await service.chatProject({ projectName: 'sre', userMessage: 'hi', ownerId: 'owner-1' }); + const body = (adapter.infer as ReturnType).mock.calls[0][0].body; + expect(body.tools).toBeUndefined(); + }); + + it('offers + dispatches get_secret with --allow-secrets when RBAC allows', async () => { + const resolve = vi.fn(async () => 'sk-secret-value'); + const rbac = { canAccess: vi.fn(async () => true) } as unknown as RbacService; + const { service } = svc({ + rbac, secrets: { resolve }, + adapter: scriptedAdapter([toolCall('secrets__get_secret', { name: 'litellm-key', key: 'API_KEY' }), text('used it')]), + }); + const res = await service.chatProject({ projectName: 'sre', userMessage: 'read the key', ownerId: 'owner-1', allowSecrets: true }); + expect(res.assistant).toBe('used it'); + expect(resolve).toHaveBeenCalledWith('litellm-key', 'API_KEY'); + }); + + it('refuses get_secret when RBAC denies view:secrets (tool never offered; hallucinated call is rejected, not resolved)', async () => { + const resolve = vi.fn(async () => 'nope'); + const rbac = { canAccess: vi.fn(async () => false) } as unknown as RbacService; + const { service } = svc({ + rbac, secrets: { resolve }, + adapter: scriptedAdapter([toolCall('secrets__get_secret', { name: 'x', key: 'y' }), text('recovered')]), + }); + // RBAC-denied → allowSecrets resolves false → the gated tool is refused. + // (Non-streaming dispatch surfaces the tool error by failing the turn.) + await expect(service.chatProject({ projectName: 'sre', userMessage: 'try', ownerId: 'owner-1', allowSecrets: true })) + .rejects.toThrow(/secret access is not enabled/); + expect(resolve).not.toHaveBeenCalled(); + }); + + it('errors when the project has no llmProvider', async () => { + const { service } = svc({ projects: mockProjects({ llmProvider: null }) }); + await expect(service.chatProject({ projectName: 'sre', userMessage: 'hi', ownerId: 'owner-1' })) + .rejects.toThrow(/no llmProvider/); + }); + + it('resume 404s on a thread owned by another user', async () => { + const chatRepo = mockChatRepo(); + chatRepo._threads.push({ id: 'other', agentId: null, projectId: 'proj-1', ownerId: 'someone-else', title: '', lastTurnAt: NOW, createdAt: NOW, updatedAt: NOW } as ChatThread); + const { service } = svc({ chatRepo }); + await expect(service.chatProject({ projectName: 'sre', threadId: 'other', userMessage: 'hi', ownerId: 'owner-1' })) + .rejects.toThrow(/Thread not found/); + }); + + it('deleteThread: owner deletes; foreign 404s unless admin override', async () => { + const chatRepo = mockChatRepo(); + chatRepo._threads.push({ id: 'mine', agentId: null, projectId: 'proj-1', ownerId: 'owner-1', title: '', lastTurnAt: NOW, createdAt: NOW, updatedAt: NOW } as ChatThread); + chatRepo._threads.push({ id: 'theirs', agentId: null, projectId: 'proj-1', ownerId: 'other', title: '', lastTurnAt: NOW, createdAt: NOW, updatedAt: NOW } as ChatThread); + const { service } = svc({ chatRepo }); + await service.deleteThread('mine', 'owner-1'); + expect(chatRepo._threads.find((t) => t.id === 'mine')).toBeUndefined(); + await expect(service.deleteThread('theirs', 'owner-1')).rejects.toThrow(/not found/); + await service.deleteThread('theirs', 'owner-1', true); // admin override + expect(chatRepo._threads.find((t) => t.id === 'theirs')).toBeUndefined(); + }); +});