From acb85b21aebb0602783f0ae0bbcd9d3f26a8c93c Mon Sep 17 00:00:00 2001 From: aperolschpritz Date: Sun, 25 Oct 2026 19:22:31 +0000 Subject: [PATCH 1/7] feat: improve mcp server discoverability and evaluation - Namespace MCP tools with quickstack_ prefix and use the package version. - Publish server instructions covering the search -> schema -> execute workflow. - Add AND search, relevance sorting, search limits and full-detail schema guards. - Truncate oversized tool results with narrowing hints. - Add limit/cursor pagination to list endpoints and document log limits. - Pass REST field validation errors through MCP tool errors. - Document write examples, upsert semantics and deploy polling. - Replace deprecated JSON-RPC codes and only export POST on the MCP route. - Add an MCP evaluation harness and Inspector smoke-test documentation. --- docs/mcp/evaluation.md | 188 +++++ package.json | 1 + scripts/mcp-eval.mjs | 661 ++++++++++++++++++ .../server/mcp/mcp-eval.integration.spec.ts | 99 +++ .../mcp/mcp-handler.integration.spec.ts | 8 +- src/app/api/mcp/route.ts | 17 +- .../settings/developer/mcp/mcp-settings.tsx | 14 +- src/server/api/v1/agent/deploy/route.ts | 2 +- src/server/api/v1/agent/route.ts | 46 +- src/server/api/v1/agent/sandbox/route.ts | 34 +- src/server/api/v1/app/deploy/route.ts | 46 +- src/server/api/v1/app/route.ts | 58 +- src/server/api/v1/project/route.ts | 47 +- src/server/mcp/mcp-errors.ts | 32 + src/server/mcp/mcp-handler.ts | 17 +- src/server/mcp/mcp-handler.unit.spec.ts | 157 ++++- src/server/mcp/mcp-server.factory.ts | 299 ++++++-- src/server/utils/api-response.utils.ts | 44 +- .../utils/api-response.utils.unit.spec.ts | 43 ++ src/shared/utils/pagination.utils.ts | 76 ++ .../utils/pagination.utils.unit.spec.ts | 77 ++ 21 files changed, 1811 insertions(+), 155 deletions(-) create mode 100644 docs/mcp/evaluation.md create mode 100644 scripts/mcp-eval.mjs create mode 100644 src/__tests__/integration/server/mcp/mcp-eval.integration.spec.ts create mode 100644 src/server/mcp/mcp-errors.ts create mode 100644 src/shared/utils/pagination.utils.ts create mode 100644 src/shared/utils/pagination.utils.unit.spec.ts diff --git a/docs/mcp/evaluation.md b/docs/mcp/evaluation.md new file mode 100644 index 00000000..5e514423 --- /dev/null +++ b/docs/mcp/evaluation.md @@ -0,0 +1,188 @@ +# MCP evaluation and Inspector smoke test + +QuickStack exposes the REST API over MCP through `/api/mcp`. Two checks keep that +surface honest: + +- `scripts/mcp-eval.mjs` measures agent success rate, tool calls, errors, token + usage and latency against a live endpoint. +- The official [MCP Inspector](https://github.com/modelcontextprotocol/inspector) + connects to the endpoint as an independent client and verifies the protocol + handshake, tool list and one representative call. + +## Prerequisites + +- A running QuickStack instance with the MCP canary and the MCP server enabled + in Settings → Developer → MCP. +- A REST API key from Settings → Account & Access → API Keys. +- Node.js 24 (uses the built-in `fetch` and `parseArgs`). + +```sh +export MCP_URL="http://localhost:3000/api/mcp" +export MCP_API_KEY="" +``` + +## Evaluation harness + +The harness talks raw JSON-RPC to the MCP endpoint, so it does not depend on an +LLM SDK. It runs the same tasks in two modes. + +### Scripted mode + +Deterministic calls that smoke-test the endpoint contract: schema discovery, +listing, pagination and optional reads. No model and no tokens are involved. + +```sh +yarn mcp:eval +``` + +This mode answers "does the MCP endpoint still expose the expected operations +and schemas?" It is the right check for CI. + +### LLM mode + +An OpenAI-compatible model receives the real MCP tool definitions and solves the +same tasks through the documented workflow: `quickstack_search_operations` → +`detail: "full"` schema → `quickstack_execute_read_operation` or +`quickstack_execute_operation`. + +```sh +EVAL_LLM_BASE_URL="https://api.openai.com/v1" \ +EVAL_LLM_API_KEY="" \ +EVAL_LLM_MODEL="gpt-4o-mini" \ +yarn mcp:eval --mode llm +``` + +`EVAL_LLM_BASE_URL` is any OpenAI-compatible base URL, for example a LiteLLM +gateway. The harness sends `temperature: 0` and stops each task after +`--max-steps` tool iterations (default 8). + +This mode answers "can an agent actually finish the task with the tools as +described?" It measures what the scripted mode cannot: tool selection, schema +reading and argument construction. + +### Optional tasks + +| Variable | Effect | +| --- | --- | +| `MCP_EVAL_APP_ID` | Adds a task that reads the last 50 log lines of that app. | +| `MCP_EVAL_AGENT_ID` | Adds a task that lists at most 5 sandboxes of that agent. | +| `MCP_EVAL_ALLOW_WRITES=1` | Adds a create-project task. The task needs an admin API key; the harness deletes the created project afterwards. | + +```sh +MCP_EVAL_APP_ID="" MCP_EVAL_AGENT_ID="" yarn mcp:eval +MCP_EVAL_ALLOW_WRITES=1 yarn mcp:eval --mode llm +``` + +### Report + +`--json` prints the full report for dashboards or regression tracking. The text +report looks like this: + +```text +QuickStack MCP evaluation (llm) +URL: http://localhost:3000/api/mcp +Server: quickstack 0.1.0 protocol=2026-07-28 instructions=true + +Success: 4/5 tasks (80.0%) +Tool calls: 11 (1 errors, 9.1%) +Tokens: 9120 in / 640 out = 9760 total +Duration: 18.4s + + PASSED find-deploy-operation calls=1 errors=0 820ms tokens=610 + PASSED list-projects calls=2 errors=0 1200ms tokens=1340 + FAILED paginate-projects calls=2 errors=1 1500ms tokens=1580 + error in listProjects: QuickStack returned HTTP 422: ... +``` + +Metrics: + +| Metric | Meaning | +| --- | --- | +| `successRate` | Tasks whose verification predicate passed. | +| `toolCalls` | MCP `tools/call` requests sent, including failed calls. | +| `toolErrors` | Calls that returned `isError: true` or failed at the transport level. | +| `inputTokens` / `outputTokens` | Sum of the model's `usage` fields across steps. | +| `avgToolCallsPerTask` | Tool calls divided by tasks, a rough efficiency signal. | +| `durationMs` | Wall-clock time for the whole run. | + +Exit code is `1` when a task fails or no task ran, so the harness can gate CI. +Tool errors are reported even when a later retry still completes the task; use +the error rate to catch descriptions that provoke wrong calls. + +### Adding a task + +Tasks live in `readOnlyTasks()` and `writeTasks()` inside +`scripts/mcp-eval.mjs`. A task needs: + +- `id` and `goal` (the goal is the prompt in LLM mode), +- `script` (the deterministic calls for scripted mode), +- `verify({ calls })` (return `true` only when the task outcome is correct), +- optional `cleanup(client, calls)`. + +`calls` entries contain `tool` (`search`, `read`, `write`), `args`, `result`, +`isError` and `durationMs`. Use `executedResult(call)` to read the REST payload +from a tool response and `searchOperations(calls)` to inspect discovery output. + +## MCP Inspector + +MCP Inspector is an independent client. It verifies that the endpoint works for +a real MCP host, not only for the project's own harness. + +Pin the Inspector version in CI (`@2.12.0` at the time of writing) so a later +release cannot change the result. + +### Handshake and tool list + +```sh +npx --yes @modelcontextprotocol/inspector@2.12.0 --cli \ + --transport http \ + --server-url "$MCP_URL" \ + --header "Authorization: Bearer $MCP_API_KEY" \ + --method initialize \ + --format json + +npx --yes @modelcontextprotocol/inspector@2.12.0 --cli \ + --transport http \ + --server-url "$MCP_URL" \ + --header "Authorization: Bearer $MCP_API_KEY" \ + --method tools/list \ + --format json +``` + +### Representative call + +The read tool is safe to call repeatedly: + +```sh +npx --yes @modelcontextprotocol/inspector@2.12.0 --cli \ + --transport http \ + --server-url "$MCP_URL" \ + --header "Authorization: Bearer $MCP_API_KEY" \ + --method tools/call \ + --tool-name quickstack_execute_read_operation \ + --tool-args-json '{"operationId":"listProjects"}' \ + --format json +``` + +A healthy `tools/call` exits `0` and includes +`.result.structuredContent.result` in the JSON envelope. Exit `5` means the tool +returned `isError: true`; exit `3` means the endpoint rejected the API key. + +### Web UI + +```sh +npx --yes @modelcontextprotocol/inspector@2.12.0 +``` + +Use transport `HTTP`, the endpoint URL and an `Authorization: Bearer ` +header. The Tools tab lists the three namespaced tools and lets you inspect their +input schemas. + +### Notes + +- The endpoint is stateless and only accepts `POST`. `GET` and `DELETE` return + `405 Method Not Allowed`. +- The server validates the `Origin` header for browser requests. The Inspector + CLI and web UI are not browsers and do not send a foreign `Origin`. +- Do not pass the API key in the URL. The Inspector redacts configured headers + but not credentials embedded in a URL. diff --git a/package.json b/package.json index de8bf1a1..7cb86acc 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "test:watch": "vitest", "test:ui": "vitest --ui", "test:coverage": "vitest --coverage", + "mcp:eval": "node scripts/mcp-eval.mjs", "openapi-generate": "bash scripts/update-k8s-custom-crds.sh && bash scripts/update-openapi.sh" }, "dependencies": { diff --git a/scripts/mcp-eval.mjs b/scripts/mcp-eval.mjs new file mode 100644 index 00000000..b9d79843 --- /dev/null +++ b/scripts/mcp-eval.mjs @@ -0,0 +1,661 @@ +#!/usr/bin/env node +/** + * QuickStack MCP evaluation harness. + * + * Runs realistic tasks against a live QuickStack MCP endpoint and reports + * success rate, tool calls, tool errors, token usage and latency. + * + * Modes: + * scripted - deterministic MCP calls that smoke-test the endpoint contract. + * llm - an OpenAI-compatible model drives the MCP tools through a + * search -> inspect schema -> execute loop. + * + * Usage: + * MCP_API_KEY=... yarn mcp:eval + * MCP_API_KEY=... EVAL_LLM_API_KEY=... EVAL_LLM_MODEL=... yarn mcp:eval --mode llm + * MCP_API_KEY=... yarn mcp:eval --json + * MCP_API_KEY=... MCP_EVAL_APP_ID=... MCP_EVAL_AGENT_ID=... yarn mcp:eval + * MCP_API_KEY=... MCP_EVAL_ALLOW_WRITES=1 yarn mcp:eval --allow-writes + */ + +import { parseArgs } from 'node:util'; + +const PROTOCOL_VERSION = '2026-07-28'; +const TOOL_SEARCH = 'quickstack_search_operations'; +const TOOL_READ = 'quickstack_execute_read_operation'; +const TOOL_WRITE = 'quickstack_execute_operation'; +const MAX_TOOL_RESULT_CHARS = 8_000; + +const { values: options } = parseArgs({ + allowPositionals: false, + options: { + url: { type: 'string', default: process.env.MCP_URL ?? 'http://localhost:3000/api/mcp' }, + 'api-key': { type: 'string', default: process.env.MCP_API_KEY ?? '' }, + mode: { type: 'string', default: process.env.MCP_EVAL_MODE ?? 'scripted' }, + 'max-steps': { type: 'string', default: '8' }, + 'allow-writes': { type: 'boolean', default: process.env.MCP_EVAL_ALLOW_WRITES === '1' }, + json: { type: 'boolean', default: false }, + help: { type: 'boolean', default: false }, + }, +}); + +if (options.help) { + printHelp(); + process.exit(0); +} + +const mode = options.mode; +if (mode !== 'scripted' && mode !== 'llm') { + fail(`Unknown mode "${mode}". Use scripted or llm.`); +} +if (!options['api-key']) { + fail('Missing API key. Set MCP_API_KEY or pass --api-key.'); +} +if (mode === 'llm' && (!process.env.EVAL_LLM_API_KEY || !process.env.EVAL_LLM_MODEL)) { + fail('LLM mode needs EVAL_LLM_API_KEY and EVAL_LLM_MODEL (optionally EVAL_LLM_BASE_URL).'); +} + +const maxSteps = Number.parseInt(options['max-steps'], 10); +if (!Number.isInteger(maxSteps) || maxSteps < 1) { + fail('--max-steps must be a positive integer.'); +} + +class McpClient { + constructor(url, apiKey) { + this.url = url; + this.apiKey = apiKey; + this.nextId = 1; + } + + async request(method, params = {}) { + const id = this.nextId++; + const headers = { + 'content-type': 'application/json', + accept: 'application/json, text/event-stream', + 'mcp-protocol-version': PROTOCOL_VERSION, + 'mcp-method': method, + authorization: `Bearer ${this.apiKey}`, + }; + if (method === 'tools/call' && params.name) { + headers['mcp-name'] = params.name; + } + + const response = await fetch(this.url, { + method: 'POST', + headers, + body: JSON.stringify({ + jsonrpc: '2.0', + id, + method, + params: { + ...params, + _meta: { + 'io.modelcontextprotocol/protocolVersion': PROTOCOL_VERSION, + 'io.modelcontextprotocol/clientCapabilities': {}, + 'io.modelcontextprotocol/clientInfo': { name: 'quickstack-mcp-eval', version: '1.0.0' }, + }, + }, + }), + }); + + const payload = await readJsonRpcPayload(response); + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${payload?.error?.message ?? response.statusText}`); + } + if (payload?.error) { + throw new Error(`JSON-RPC ${payload.error.code}: ${payload.error.message}`); + } + return payload?.result; + } + + async discover() { + return this.request('server/discover'); + } + + async initialize() { + return this.request('initialize', { + protocolVersion: PROTOCOL_VERSION, + capabilities: {}, + clientInfo: { name: 'quickstack-mcp-eval', version: '1.0.0' }, + }); + } + + async listTools() { + return this.request('tools/list'); + } + + async callTool(name, args) { + const result = await this.request('tools/call', { name, arguments: args }); + const isError = result?.isError === true; + return { result, isError }; + } +} + +async function readJsonRpcPayload(response) { + const contentType = response.headers.get('content-type') ?? ''; + const text = await response.text(); + if (!text) { + return undefined; + } + if (contentType.includes('text/event-stream')) { + const dataLines = text + .split('\n') + .filter((line) => line.startsWith('data:')) + .map((line) => line.slice('data:'.length).trim()) + .filter(Boolean); + const last = dataLines.at(-1); + return last ? JSON.parse(last) : undefined; + } + try { + return JSON.parse(text); + } catch { + return { error: { code: -32700, message: text.slice(0, 500) } }; + } +} + +function redact(value) { + if (typeof value !== 'string') { + return value; + } + return value.length > 300 ? `${value.slice(0, 300)}...` : value; +} + +function summarizeToolResult(result) { + if (!result) { + return ''; + } + const structured = result.structuredContent; + const text = structured ? JSON.stringify(structured) : (result.content ?? []) + .map((entry) => entry.text ?? '') + .join('\n'); + const normalized = result.isError ? `[tool error] ${text}` : text; + return normalized.length > MAX_TOOL_RESULT_CHARS + ? `${normalized.slice(0, MAX_TOOL_RESULT_CHARS)}\n[truncated by eval harness]` + : normalized; +} + +function searchOperations(calls) { + return calls + .filter((call) => call.tool === 'search' && !call.isError) + .flatMap((call) => call.result?.structuredContent?.operations ?? []); +} + +function executedResult(call) { + return call?.result?.structuredContent?.result; +} + +function readOnlyTasks() { + const tasks = [ + { + id: 'find-deploy-operation', + goal: 'Find the QuickStack operation that deploys an App and report its full input schema. Do not deploy anything.', + script: [{ tool: 'search', args: { search: 'deploy app', detail: 'full' } }], + verify({ calls }) { + const deployApp = searchOperations(calls).find((operation) => operation.operationId === 'deployApp'); + return Boolean(deployApp?.querySchema && deployApp?.pathParamsSchema); + }, + }, + { + id: 'list-projects', + goal: 'List the QuickStack projects. Do not create, update or delete anything.', + script: [{ tool: 'read', args: { operationId: 'listProjects' } }], + verify({ calls }) { + const call = calls.find((candidate) => candidate.tool === 'read' && !candidate.isError); + return Boolean(call) && Array.isArray(executedResult(call)); + }, + }, + { + id: 'paginate-projects', + goal: 'List at most 1 project using the pagination parameters and report the total number of projects. Do not change anything.', + script: [{ tool: 'read', args: { operationId: 'listProjects', query: { limit: 1 } } }], + verify({ calls }) { + const result = executedResult(calls.find((call) => call.tool === 'read' && !call.isError)); + return Boolean( + result + && Array.isArray(result.items) + && result.items.length <= 1 + && typeof result.total === 'number' + && typeof result.limit === 'number' + ); + }, + }, + ]; + + const appId = process.env.MCP_EVAL_APP_ID; + if (appId) { + tasks.push({ + id: 'inspect-app-logs', + goal: `Read the last 50 log lines of app "${appId}". Do not change anything.`, + script: [{ + tool: 'read', + args: { operationId: 'getAppLogs', pathParams: { appId }, query: { lines: 50 } }, + }], + verify({ calls }) { + const result = executedResult(calls.find((call) => call.tool === 'read' && !call.isError)); + return Boolean(result && result.lines === 50 && Array.isArray(result.logs)); + }, + }); + } + + const agentId = process.env.MCP_EVAL_AGENT_ID; + if (agentId) { + tasks.push({ + id: 'list-agent-sandboxes', + goal: `List at most 5 sandboxes of agent "${agentId}". Do not start or delete a sandbox.`, + script: [{ + tool: 'read', + args: { operationId: 'listAgentSandboxes', pathParams: { agentId }, query: { limit: 5 } }, + }], + verify({ calls }) { + const result = executedResult(calls.find((call) => call.tool === 'read' && !call.isError)); + return Boolean( + result + && Array.isArray(result.items) + && result.items.length <= 5 + && typeof result.total === 'number' + ); + }, + }); + } + + return tasks; +} + +function writeTasks(runId) { + const projectName = `mcp-eval-${runId}`; + return [{ + id: 'create-project', + goal: `Create a QuickStack project named "${projectName}" and report its id. Do not create or delete anything else.`, + script: [{ + tool: 'write', + args: { operationId: 'saveProject', body: { name: projectName } }, + }], + verify({ calls }) { + const call = calls.find((candidate) => + candidate.tool === 'write' + && candidate.args?.operationId === 'saveProject' + && !candidate.isError + ); + const result = executedResult(call); + return Boolean(result?.id && result?.name === projectName); + }, + async cleanup(client, calls) { + const created = calls + .filter((call) => + call.tool === 'write' + && call.args?.operationId === 'saveProject' + && !call.isError + ) + .map(executedResult) + .find((result) => result?.id && result?.name === projectName); + if (!created?.id) { + return; + } + await client.callTool(TOOL_WRITE, { + operationId: 'deleteProject', + pathParams: { id: created.id }, + }); + }, + }]; +} + +class ScriptedRunner { + async runTask(client, task) { + const calls = []; + for (const step of task.script) { + const startedAt = Date.now(); + const tool = step.tool === 'search' ? TOOL_SEARCH : step.tool === 'read' ? TOOL_READ : TOOL_WRITE; + const execution = await executeTool(client, tool, step.args); + calls.push({ ...execution, tool: step.tool, durationMs: Date.now() - startedAt }); + } + return calls; + } +} + +class LlmRunner { + constructor(config) { + this.baseUrl = (config.baseUrl ?? 'https://api.openai.com/v1').replace(/\/$/, ''); + this.apiKey = config.apiKey; + this.model = config.model; + } + + async runTask(client, task, tools) { + const messages = [ + { + role: 'system', + content: [ + 'You are evaluating the QuickStack MCP server.', + 'Use the provided MCP tools. Start with quickstack_search_operations to find the operation id.', + 'Inspect detail "full" before executing a write operation.', + 'Never perform operations the task did not ask for. In particular do not delete or deploy anything unless the task says so.', + 'When finished, reply with a short summary. Do not invent tool results.', + ].join(' '), + }, + { role: 'user', content: task.goal }, + ]; + const calls = []; + let inputTokens = 0; + let outputTokens = 0; + + for (let step = 0; step < maxSteps; step++) { + const response = await fetch(`${this.baseUrl}/chat/completions`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + authorization: `Bearer ${this.apiKey}`, + }, + body: JSON.stringify({ + model: this.model, + messages, + tools, + temperature: 0, + }), + }); + if (!response.ok) { + throw new Error(`LLM HTTP ${response.status}: ${(await response.text()).slice(0, 500)}`); + } + const body = await response.json(); + inputTokens += body.usage?.prompt_tokens ?? 0; + outputTokens += body.usage?.completion_tokens ?? 0; + + const message = body.choices?.[0]?.message; + if (!message) { + throw new Error('LLM response contained no message.'); + } + if (!message.tool_calls?.length) { + break; + } + + messages.push({ + role: 'assistant', + content: message.content ?? null, + tool_calls: message.tool_calls, + }); + + for (const toolCall of message.tool_calls) { + const tool = toolCall.function?.name; + const args = parseToolArguments(toolCall.function?.arguments); + const toolKind = tool === TOOL_SEARCH ? 'search' : tool === TOOL_READ ? 'read' : 'write'; + const startedAt = Date.now(); + let execution; + try { + execution = await executeTool(client, tool, args); + } catch (error) { + execution = { + result: undefined, + isError: true, + errorMessage: error instanceof Error ? error.message : String(error), + }; + } + const call = { ...execution, tool: toolKind, args, durationMs: Date.now() - startedAt }; + calls.push(call); + messages.push({ + role: 'tool', + tool_call_id: toolCall.id, + content: execution.isError + ? `Error: ${execution.errorMessage ?? summarizeToolResult(execution.result)}` + : summarizeToolResult(execution.result), + }); + } + } + + return { calls, inputTokens, outputTokens }; + } +} + +async function executeTool(client, name, args) { + try { + const { result, isError } = await client.callTool(name, args); + return { + name, + args, + result, + isError, + errorMessage: isError ? summarizeToolResult(result) : undefined, + }; + } catch (error) { + return { + name, + args, + result: undefined, + isError: true, + errorMessage: error instanceof Error ? error.message : String(error), + }; + } +} + +function parseToolArguments(raw) { + if (!raw) { + return {}; + } + try { + return JSON.parse(raw); + } catch { + return { __invalidArguments: redact(raw) }; + } +} + +function verifyTask(task, calls) { + try { + return task.verify({ calls }); + } catch { + return false; + } +} + +async function main() { + const startedAt = Date.now(); + const runId = `${new Date().toISOString().replace(/[:.]/g, '-')}-${Math.random().toString(36).slice(2, 6)}`; + const tasks = readOnlyTasks(); + if (options['allow-writes']) { + tasks.push(...writeTasks(runId)); + } + + const client = new McpClient(options.url, options['api-key']); + let tools = []; + let serverInfo; + + try { + try { + serverInfo = await client.initialize(); + } catch { + serverInfo = await client.discover(); + } + const toolsResult = await client.listTools(); + tools = toolsResult?.tools ?? []; + } catch (error) { + fail(`MCP connection failed: ${error instanceof Error ? error.message : String(error)}`); + } + + if (tools.length === 0) { + fail('MCP server returned no tools.'); + } + + const llmRunner = mode === 'llm' + ? new LlmRunner({ + baseUrl: process.env.EVAL_LLM_BASE_URL, + apiKey: process.env.EVAL_LLM_API_KEY, + model: process.env.EVAL_LLM_MODEL, + }) + : undefined; + const scriptedRunner = new ScriptedRunner(); + const openAiTools = tools.map((tool) => ({ + type: 'function', + function: { + name: tool.name, + description: tool.description ?? '', + parameters: tool.inputSchema ?? { type: 'object', properties: {} }, + }, + })); + + const results = []; + for (const task of tasks) { + const taskStartedAt = Date.now(); + try { + const run = mode === 'llm' + ? await llmRunner.runTask(client, task, openAiTools) + : { calls: await scriptedRunner.runTask(client, task), inputTokens: 0, outputTokens: 0 }; + const calls = run.calls; + const passed = verifyTask(task, calls); + + if (task.cleanup) { + try { + await task.cleanup(client, calls); + } catch (error) { + calls.push({ + tool: 'write', + args: { operationId: 'cleanup' }, + isError: true, + errorMessage: `Cleanup failed: ${error instanceof Error ? error.message : String(error)}`, + durationMs: 0, + }); + } + } + + results.push({ + id: task.id, + status: passed ? 'passed' : 'failed', + calls, + inputTokens: run.inputTokens, + outputTokens: run.outputTokens, + durationMs: Date.now() - taskStartedAt, + }); + } catch (error) { + results.push({ + id: task.id, + status: 'failed', + calls: [], + inputTokens: 0, + outputTokens: 0, + durationMs: Date.now() - taskStartedAt, + message: error instanceof Error ? error.message : String(error), + }); + } + } + + const report = buildReport({ mode, url: options.url, runId, serverInfo, results, startedAt }); + if (options.json) { + console.log(JSON.stringify(report, null, 2)); + } else { + printReport(report); + } + + process.exitCode = report.summary.failed > 0 || report.summary.passed === 0 ? 1 : 0; +} + +function buildReport({ mode, url, runId, serverInfo, results, startedAt }) { + const calls = results.flatMap((result) => result.calls); + const toolCalls = calls.length; + const toolErrors = calls.filter((call) => call.isError).length; + const inputTokens = results.reduce((sum, result) => sum + result.inputTokens, 0); + const outputTokens = results.reduce((sum, result) => sum + result.outputTokens, 0); + const passed = results.filter((result) => result.status === 'passed').length; + const failed = results.filter((result) => result.status === 'failed').length; + + return { + mode, + url, + runId, + server: { + name: serverInfo?.serverInfo?.name ?? serverInfo?.name, + version: serverInfo?.serverInfo?.version ?? serverInfo?.version, + instructions: Boolean(serverInfo?.instructions), + protocolVersion: serverInfo?.protocolVersion, + }, + summary: { + tasks: results.length, + passed, + failed, + successRate: results.length > 0 ? round(passed / results.length) : 0, + toolCalls, + toolErrors, + errorRate: toolCalls > 0 ? round(toolErrors / toolCalls) : 0, + avgToolCallsPerTask: results.length > 0 ? round(toolCalls / results.length) : 0, + inputTokens, + outputTokens, + totalTokens: inputTokens + outputTokens, + durationMs: Date.now() - startedAt, + }, + tasks: results.map((result) => ({ + id: result.id, + status: result.status, + toolCalls: result.calls.length, + toolErrors: result.calls.filter((call) => call.isError).length, + inputTokens: result.inputTokens, + outputTokens: result.outputTokens, + durationMs: result.durationMs, + ...(result.message ? { message: result.message } : {}), + calls: result.calls.map((call) => ({ + tool: call.tool, + operationId: call.args?.operationId, + isError: call.isError, + durationMs: call.durationMs, + ...(call.errorMessage ? { error: redact(call.errorMessage) } : {}), + })), + })), + }; +} + +function printReport(report) { + const { summary, server } = report; + console.log(`QuickStack MCP evaluation (${report.mode})`); + console.log(`URL: ${report.url}`); + if (server.name) { + console.log(`Server: ${server.name} ${server.version ?? ''} protocol=${server.protocolVersion ?? 'unknown'} instructions=${server.instructions}`); + } + console.log(''); + console.log(`Success: ${summary.passed}/${summary.tasks} tasks (${(summary.successRate * 100).toFixed(1)}%)`); + console.log(`Tool calls: ${summary.toolCalls} (${summary.toolErrors} errors, ${(summary.errorRate * 100).toFixed(1)}%)`); + console.log(`Tokens: ${summary.inputTokens} in / ${summary.outputTokens} out = ${summary.totalTokens} total`); + console.log(`Duration: ${(summary.durationMs / 1000).toFixed(1)}s`); + console.log(''); + for (const task of report.tasks) { + const tokens = task.inputTokens + task.outputTokens; + const suffix = tokens > 0 ? ` tokens=${tokens}` : ''; + console.log(` ${task.status.toUpperCase().padEnd(6)} ${task.id} calls=${task.toolCalls} errors=${task.toolErrors} ${task.durationMs}ms${suffix}`); + if (task.message) { + console.log(` ${task.message}`); + } + for (const call of task.calls.filter((entry) => entry.isError)) { + console.log(` error in ${call.operationId ?? call.tool}: ${call.error}`); + } + } +} + +function round(value) { + return Math.round(value * 1000) / 1000; +} + +function printHelp() { + console.log(`QuickStack MCP evaluation harness + +Environment: + MCP_URL MCP endpoint (default http://localhost:3000/api/mcp) + MCP_API_KEY QuickStack REST API key + MCP_EVAL_MODE scripted (default) or llm + MCP_EVAL_APP_ID Optional app id for the app-logs task + MCP_EVAL_AGENT_ID Optional agent id for the sandbox task + MCP_EVAL_ALLOW_WRITES=1 Add the create-project write task (cleaned up afterwards) + EVAL_LLM_BASE_URL OpenAI-compatible base URL (default https://api.openai.com/v1) + EVAL_LLM_API_KEY LLM API key (llm mode) + EVAL_LLM_MODEL LLM model name (llm mode) + +Flags: + --url MCP endpoint + --api-key QuickStack REST API key + --mode scripted|llm Evaluation mode + --max-steps Maximum LLM tool loop iterations per task (default 8) + --allow-writes Add the write task + --json Print the full report as JSON + --help Show this help`); +} + +function fail(message) { + console.error(message); + process.exit(1); +} + +main().catch((error) => { + console.error(error instanceof Error ? error.stack : error); + process.exit(1); +}); diff --git a/src/__tests__/integration/server/mcp/mcp-eval.integration.spec.ts b/src/__tests__/integration/server/mcp/mcp-eval.integration.spec.ts new file mode 100644 index 00000000..c33af21c --- /dev/null +++ b/src/__tests__/integration/server/mcp/mcp-eval.integration.spec.ts @@ -0,0 +1,99 @@ +// @vitest-environment node + +import { spawn } from 'node:child_process'; +import { createServer } from 'node:http'; +import { Elysia } from 'elysia'; +import { z } from 'zod'; +import { createQuickStackMcpHandler } from '@/server/mcp/mcp-handler'; + +function runHarness(url: string, apiKey: string): Promise<{ code: number | null; stdout: string; stderr: string }> { + return new Promise((resolve, reject) => { + const child = spawn(process.execPath, ['scripts/mcp-eval.mjs', '--url', url, '--api-key', apiKey], { + cwd: process.cwd(), + env: process.env, + }); + let stdout = ''; + let stderr = ''; + child.stdout.on('data', (chunk) => { stdout += chunk.toString(); }); + child.stderr.on('data', (chunk) => { stderr += chunk.toString(); }); + child.on('error', reject); + child.on('close', (code) => resolve({ code, stdout, stderr })); + }); +} + +describe('MCP evaluation harness', () => { + it('runs the scripted tasks against the real MCP handler', async () => { + const fixture = new Elysia({ prefix: '/api/v1' }) + .get('/projects', ({ query }) => { + const projects = [{ id: 'p1', name: 'Demo' }, { id: 'p2', name: 'Second' }]; + if (query.limit === undefined) return projects; + const limit = Number(query.limit); + return { + items: projects.slice(0, limit), + total: projects.length, + limit, + nextCursor: limit < projects.length ? String(limit) : null, + }; + }, { + query: z.object({ limit: z.coerce.number().optional() }), + response: { 200: z.union([z.array(z.object({ id: z.string(), name: z.string() })), z.object({ + items: z.array(z.object({ id: z.string(), name: z.string() })), + total: z.number(), + limit: z.number(), + nextCursor: z.string().nullable(), + })]) }, + detail: { operationId: 'listProjects', summary: 'List projects', tags: ['Projects'] }, + }) + .post('/apps/:appId/deploy', ({ params }) => ({ deploymentId: `deploy-${params.appId}` }), { + params: z.object({ appId: z.string() }), + query: z.object({ forceRebuild: z.coerce.boolean().optional() }), + response: { 200: z.object({ deploymentId: z.string() }) }, + detail: { operationId: 'deployApp', summary: 'Deploy app', description: 'Deploy an app.', tags: ['Apps'] }, + }); + + const handler = createQuickStackMcpHandler({ + app: fixture, + resolveAuthInfo: async () => ({ token: 'eval-token', clientId: 'eval', scopes: [] }), + }); + const server = createServer(async (request, response) => { + const chunks: Buffer[] = []; + for await (const chunk of request) { + chunks.push(Buffer.from(chunk)); + } + const method = request.method ?? 'GET'; + const body = method === 'GET' || method === 'HEAD' + ? undefined + : new Uint8Array(Buffer.concat(chunks)); + const fetchResponse = await handler.fetch(new Request( + `http://127.0.0.1${request.url ?? '/api/mcp'}`, + { + method, + headers: request.headers as Record, + body, + } + )); + response.statusCode = fetchResponse.status; + fetchResponse.headers.forEach((value, key) => response.setHeader(key, value)); + response.end(Buffer.from(await fetchResponse.arrayBuffer())); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + + try { + const address = server.address(); + if (!address || typeof address === 'string') { + throw new Error('Test server did not expose a port.'); + } + const { code, stdout, stderr } = await runHarness( + `http://127.0.0.1:${address.port}/api/mcp`, + 'eval-token' + ); + + expect(stderr).toBe(''); + expect(stdout).toContain('Success: 3/3 tasks'); + expect(code).toBe(0); + } finally { + await handler.close(); + await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve())); + } + }, 60_000); +}); diff --git a/src/__tests__/integration/server/mcp/mcp-handler.integration.spec.ts b/src/__tests__/integration/server/mcp/mcp-handler.integration.spec.ts index 278e95e4..6bc245ab 100644 --- a/src/__tests__/integration/server/mcp/mcp-handler.integration.spec.ts +++ b/src/__tests__/integration/server/mcp/mcp-handler.integration.spec.ts @@ -81,9 +81,9 @@ describe('QuickStack MCP integration', () => { const response = await handler.fetch(jsonRpcRequest({ method: 'tools/call', - toolName: 'search_operations', + toolName: 'quickstack_search_operations', apiKey, - params: { name: 'search_operations', arguments: { detail: 'full' } }, + params: { name: 'quickstack_search_operations', arguments: { detail: 'summary', limit: 100 } }, })); const body = await response.json(); const operations = body.result.structuredContent.operations as Array<{ operationId: string; supported: boolean }>; @@ -104,9 +104,9 @@ describe('QuickStack MCP integration', () => { const response = await handler.fetch(jsonRpcRequest({ method: 'tools/call', - toolName: 'execute_read_operation', + toolName: 'quickstack_execute_read_operation', apiKey, - params: { name: 'execute_read_operation', arguments: { operationId: 'listProjects' } }, + params: { name: 'quickstack_execute_read_operation', arguments: { operationId: 'listProjects' } }, })); const body = await response.json(); diff --git a/src/app/api/mcp/route.ts b/src/app/api/mcp/route.ts index 32b66873..ae04ed67 100644 --- a/src/app/api/mcp/route.ts +++ b/src/app/api/mcp/route.ts @@ -1,5 +1,6 @@ import { v1Api } from '@/server/api/v1/api-index'; import { resolveQuickStackAuthInfo } from '@/server/mcp/mcp-auth'; +import { jsonRpcErrorResponse, MCP_ERROR_CODES } from '@/server/mcp/mcp-errors'; import { createQuickStackMcpHandler } from '@/server/mcp/mcp-handler'; import paramService, { ParamService } from '@/server/services/param.service'; @@ -17,18 +18,16 @@ async function handleRequest(request: Request): Promise { ]); if (!canaryEnabled || !enabled) { - return new Response( - JSON.stringify({ jsonrpc: '2.0', id: null, error: { code: -32002, message: 'MCP server is disabled.' } }), - { - status: 404, - headers: { 'content-type': 'application/json' }, - } - ); + return jsonRpcErrorResponse({ + code: MCP_ERROR_CODES.SERVER_DISABLED, + message: 'MCP server is disabled.', + status: 404, + }); } return mcpHandler.fetch(request); } +// The MCP 2026-07-28 transport is stateless: only POST is supported. +// Next.js answers unsupported methods with 405 automatically. export const POST = handleRequest; -export const GET = handleRequest; -export const DELETE = handleRequest; diff --git a/src/app/settings/developer/mcp/mcp-settings.tsx b/src/app/settings/developer/mcp/mcp-settings.tsx index a5cc019c..6204cc5d 100644 --- a/src/app/settings/developer/mcp/mcp-settings.tsx +++ b/src/app/settings/developer/mcp/mcp-settings.tsx @@ -152,9 +152,11 @@ Authorization: Bearer Connection Use the endpoint URL below and authenticate with a REST API Key created under - {' '}Settings → Account & Access → API Keys. QuickStack exposes two tools: - {' '}search_operations to discover operations and - {' '}execute_operation to run one. + {' '}Settings → Account & Access → API Keys. QuickStack exposes three + namespaced tools: quickstack_search_operations to + discover operations, quickstack_execute_read_operation for + read-only calls and quickstack_execute_operation for + mutating calls. @@ -215,8 +217,10 @@ Authorization: Bearer

- Call search_operations first to discover operation ids and their - input/output schemas, then call execute_operation with + Call quickstack_search_operations first to discover operation ids + and their input/output schemas, then call + {' '}quickstack_execute_read_operation or + {' '}quickstack_execute_operation with {' '}pathParams, query and {' '}body. Clients set the MCP protocol headers themselves.

diff --git a/src/server/api/v1/agent/deploy/route.ts b/src/server/api/v1/agent/deploy/route.ts index eb7358d9..82cbd315 100644 --- a/src/server/api/v1/agent/deploy/route.ts +++ b/src/server/api/v1/agent/deploy/route.ts @@ -29,7 +29,7 @@ export const agentDeployRoutes = new Elysia() response: ApiUtils.mapResponseModel(z.object({ deploymentId: z.string() })), detail: { summary: 'Deploy agent configuration', - description: 'Deploys the agent with the given ID. This does not start any agent sandbox, it just applies the configuration to kubernetes (SandboxTemplate). Every new agent sandbox created after this deployment will use the newly applied configuration. This can only be done, wehen no sandbox is running.', + description: 'Deploys the agent configuration with the given ID. This does not start any agent sandbox; it only applies the configuration to Kubernetes (SandboxTemplate). Every new agent sandbox created after this deployment uses the newly applied configuration. This can only be done when no sandbox is running. For CONTAINER sources the SandboxTemplate is applied before the call returns. For GIT/GIT_SSH sources the build continues asynchronously after the returned deploymentId; follow its progress in the QuickStack UI and poll listAgentSandboxes before starting a sandbox.\nExample: POST /api/v1/agents//deploy?forceRebuild=false.\nThis changes live Kubernetes resources; ask for confirmation first.', operationId: 'deployAgent', tags: ['Agents'], security: [{ bearerAuth: [] }] diff --git a/src/server/api/v1/agent/route.ts b/src/server/api/v1/agent/route.ts index 926432a0..18a1c369 100644 --- a/src/server/api/v1/agent/route.ts +++ b/src/server/api/v1/agent/route.ts @@ -11,6 +11,7 @@ import { UserGroupUtils } from '@/shared/utils/role.utils'; import { AgentExtendedModel, AgentExtendedWriteModel, AgentExtendedWriteZodModel, AgentExtendedZodModel } from '@/shared/model/agent-extended.model'; import { ApiUtils } from '../../../utils/api-response.utils'; import { ApiNotFoundException, ApiUnauthorizedException, ServiceException } from '@/shared/model/service.exception.model'; +import paginationUtils, { paginationQueryZodModel } from '@/shared/utils/pagination.utils'; function stripAgentSubObjectIdsForCreate(body: AgentExtendedWriteModel): AgentExtendedWriteModel { const agentNetworkPolicy = body.agentNetworkPolicy @@ -39,19 +40,27 @@ export const agentRoutes = new Elysia() const agents = query.projectId ? await agentService.getAllByProjectId(query.projectId) : await agentService.getAll(); - if (UserGroupUtils.isAdmin(identity.session)) { - return agents; - } + const visibleAgents = UserGroupUtils.isAdmin(identity.session) + ? agents + : agents.filter(agent => + UserGroupUtils.sessionHasReadAccessForProjectWorkload(identity.session, agent.id) + ); - return agents.filter(agent => - UserGroupUtils.sessionHasReadAccessForProjectWorkload(identity.session, agent.id) - ); + return paginationUtils.isRequested(query) + ? paginationUtils.page(visibleAgents, query) + : visibleAgents; }, { - query: z.object({ + query: paginationQueryZodModel.extend({ projectId: z.string().optional(), }), - response: ApiUtils.mapResponseModel(z.array(AgentExtendedZodModel)), - detail: { summary: 'List agents', operationId: 'listAgents', tags: ['Agents'], security: [{ bearerAuth: [] }] } + response: ApiUtils.mapResponseModel(paginationUtils.responseSchema(AgentExtendedZodModel)), + detail: { + summary: 'List agents', + description: 'Returns all agent templates as an array. Set limit (and optionally cursor) for the paginated envelope { items, total, limit, nextCursor }.', + operationId: 'listAgents', + tags: ['Agents'], + security: [{ bearerAuth: [] }], + } }) .get('/agents/:agentId', async ({ params, identity }) => { if (!identity) throw new ApiUnauthorizedException() @@ -93,7 +102,16 @@ export const agentRoutes = new Elysia() response: ApiUtils.mapResponseModel(AgentExtendedZodModel), detail: { summary: 'Create or update agent.', - description: 'When an ID is set, the agent will be updated. Otherwise a new one will be created.', + description: `Upsert one agent template. +- Omit id to create: projectId, llmGatewayId and modelAlias (non-empty array) are required. agentDomains, agentVolumes, agentFileMounts are required arrays, and agentNetworkPolicy must be null or an object. +- Send id to update: projectId must equal the existing agent's projectId and cannot be changed. The request replaces the stored agent, so omitted or empty sub-resource arrays delete the existing entries. Call getAgent first, then send the full object back with the ids of sub-resources you want to keep. +- This only saves the template. Call deployAgent to apply it, then startAgentSandbox to run a sandbox. Saving revalidates caches but does not create Kubernetes resources. + +Example create: +{"name":"coder","projectId":"","llmGatewayId":"","modelAlias":["gpt-4o"],"sourceType":"CONTAINER","buildMethod":"DOCKERFILE","containerImageSource":"ghcr.io/example/agent:latest","dockerfilePath":"./Dockerfile","warmPoolReplicas":0,"deployFileBrowser":false,"healthCheckPeriodSeconds":15,"healthCheckTimeoutSeconds":5,"healthCheckFailureThreshold":3,"agentDomains":[],"agentVolumes":[],"agentFileMounts":[],"agentNetworkPolicy":null} + +Example update (read getAgent first and keep all sub-resource ids you do not want to delete): +{"id":"","name":"coder","projectId":"","llmGatewayId":"","modelAlias":["gpt-4o","gpt-4o-mini"],"sourceType":"CONTAINER","buildMethod":"DOCKERFILE","containerImageSource":"ghcr.io/example/agent:latest","dockerfilePath":"./Dockerfile","warmPoolReplicas":1,"deployFileBrowser":false,"healthCheckPeriodSeconds":15,"healthCheckTimeoutSeconds":5,"healthCheckFailureThreshold":3,"agentDomains":[],"agentVolumes":[{"id":"","containerMountPath":"/workspace","size":10,"storageClassName":"longhorn","volumeType":"PER_SANDBOX"}],"agentFileMounts":[],"agentNetworkPolicy":{"allowInternetAccess":true,"rules":[]}}`, operationId: 'saveAgent', tags: ['Agents'], security: [{ bearerAuth: [] }] @@ -114,5 +132,11 @@ export const agentRoutes = new Elysia() agentId: z.string(), }), response: ApiUtils.mapResponseModel(z.undefined()), - detail: { summary: 'Delete agent', operationId: 'deleteAgent', tags: ['Agents'], security: [{ bearerAuth: [] }] } + detail: { + summary: 'Delete agent', + description: 'Permanently deletes the agent template and its sub-resources (domains, volumes, file mounts, network policy). No request body.\nExample: DELETE /api/v1/agents/.\nRequires confirmation because this cannot be undone.', + operationId: 'deleteAgent', + tags: ['Agents'], + security: [{ bearerAuth: [] }], + } }); diff --git a/src/server/api/v1/agent/sandbox/route.ts b/src/server/api/v1/agent/sandbox/route.ts index 6c0ecdeb..9910d53d 100644 --- a/src/server/api/v1/agent/sandbox/route.ts +++ b/src/server/api/v1/agent/sandbox/route.ts @@ -25,6 +25,7 @@ import { ApiNotFoundException, ApiUnauthorizedException } from '@/shared/model/s import { ApiUtils } from '@/server/utils/api-response.utils'; import agentAccessService from '@/server/services/agent-access.service'; import { FileUploadUtils } from '@/server/utils/file-upload.utils'; +import paginationUtils, { paginationQueryZodModel } from '@/shared/utils/pagination.utils'; const agentSandboxParamsSchema = z.object({ agentId: z.string(), @@ -67,19 +68,24 @@ async function ensureAgentExists(agentId: string) { export const agentSandboxRoutes = new Elysia() .derive(ApiUtils.deriveFunc) - .get('/agents/:agentId/sandboxes', async ({ params, identity }) => { + .get('/agents/:agentId/sandboxes', async ({ params, query, identity }) => { if (!identity) throw new ApiUnauthorizedException(); await ensureAgentExists(params.agentId); ensureReadAgent(identity, params.agentId); - return agentSandboxService.listSandboxes(params.agentId); + const sandboxes = await agentSandboxService.listSandboxes(params.agentId); + + return paginationUtils.isRequested(query) + ? paginationUtils.page(sandboxes, query) + : sandboxes; }, { params: agentSandboxParamsSchema, - response: ApiUtils.mapResponseModel(z.array(agentSandboxZodModel)), + query: paginationQueryZodModel, + response: ApiUtils.mapResponseModel(paginationUtils.responseSchema(agentSandboxZodModel)), detail: { summary: 'List agent sandboxes', - description: 'List all running agent sandboxes for the given agent ID.', + description: 'List all running agent sandboxes for the given agent ID as an array. Set limit (and optionally cursor) for the paginated envelope { items, total, limit, nextCursor }.', operationId: 'listAgentSandboxes', tags: ['Agent Sandboxes'], security: [{ bearerAuth: [] }], @@ -101,6 +107,9 @@ export const agentSandboxRoutes = new Elysia() response: ApiUtils.mapResponseModel(agentSandboxZodModel), detail: { summary: 'Start agent sandbox', + description: `Creates and starts one agent sandbox for this agent. Body is optional and defaults to {}. +Example: POST /api/v1/agents//sandboxes?timeoutMs=300000 with body {"customTag":"review","idleTimeoutMinutes":30,"env":{"FOO":"bar"}} +customTag must be 1-63 characters; idleTimeoutMinutes is 1-1440; env names must match [A-Za-z_][A-Za-z0-9_]*. Query timeoutMs (max 900000, default 300000) controls how long the API waits for the sandbox to start. This creates live Kubernetes resources; ask for confirmation first.`, operationId: 'startAgentSandbox', tags: ['Agent Sandboxes'], security: [{ bearerAuth: [] }], @@ -119,7 +128,7 @@ export const agentSandboxRoutes = new Elysia() response: ApiUtils.mapResponseModel(agentSandboxZodModel), detail: { summary: 'Resume agent sandbox by Custom Tag', - description: 'Resume the suspended Agent Sandbox of this Agent that carries the given Custom Tag.', + description: 'Resume the suspended Agent Sandbox of this Agent that carries the given Custom Tag. Recreates the Pod with the existing volumes mounted.\nExample: POST /api/v1/agents//sandboxes/resume with body {"customTag":"review"}.', operationId: 'resumeAgentSandboxByTag', tags: ['Agent Sandboxes'], security: [{ bearerAuth: [] }], @@ -137,7 +146,7 @@ export const agentSandboxRoutes = new Elysia() response: ApiUtils.mapResponseModel(agentSandboxZodModel), detail: { summary: 'Suspend agent sandbox', - description: 'Release the compute of a running Agent Sandbox while keeping its volumes and identity.', + description: 'Release the compute of a running Agent Sandbox while keeping its volumes and identity. No request body. The sandbox Pod is deleted and can be resumed later.\nExample: POST /api/v1/agents//sandboxes//suspend.', operationId: 'suspendAgentSandbox', tags: ['Agent Sandboxes'], security: [{ bearerAuth: [] }], @@ -155,7 +164,7 @@ export const agentSandboxRoutes = new Elysia() response: ApiUtils.mapResponseModel(agentSandboxZodModel), detail: { summary: 'Resume agent sandbox', - description: 'Recreate the Pod of a suspended Agent Sandbox with its existing volumes mounted.', + description: 'Recreate the Pod of a suspended Agent Sandbox with its existing volumes mounted. No request body.\nExample: POST /api/v1/agents//sandboxes//resume.', operationId: 'resumeAgentSandbox', tags: ['Agent Sandboxes'], security: [{ bearerAuth: [] }], @@ -201,7 +210,7 @@ export const agentSandboxRoutes = new Elysia() response: ApiUtils.mapResponseModel(agentSandboxAccessUrlZodModel), detail: { summary: 'Get agent sandbox access URL', - description: 'Get a temporary access URL to access the agent sandbox via the browser. This is only possible if a domain is configured for the agent and the agent serves an API or Web interface.', + description: 'Get a temporary access URL to access the agent sandbox via the browser. This is only possible if a domain is configured for the agent and the agent serves an API or Web interface. No request body; the returned URL expires.\nExample: GET /api/v1/agents//sandboxes//accessUrl/.', operationId: 'getAgentSandboxAccessUrl', tags: ['Agent Sandboxes'], security: [{ bearerAuth: [] }], @@ -224,7 +233,7 @@ export const agentSandboxRoutes = new Elysia() response: ApiUtils.mapResponseModel(agentSandboxApiTokenZodModel), detail: { summary: 'Create agent sandbox API token', - description: 'Create a bearer token for direct API access to this Agent Sandbox through a configured domain. The token expires after 15 minutes.', + description: 'Create a bearer token for direct API access to this Agent Sandbox through a configured domain. No request body. The token expires after 15 minutes.\nExample: POST /api/v1/agents//sandboxes//apiToken/.', operationId: 'createAgentSandboxApiToken', tags: ['Agent Sandboxes'], security: [{ bearerAuth: [] }], @@ -243,6 +252,7 @@ export const agentSandboxRoutes = new Elysia() response: ApiUtils.mapResponseModel(z.undefined()), detail: { summary: 'Delete agent sandbox', + description: 'Permanently deletes the agent sandbox and stops its Pod. No request body.\nExample: DELETE /api/v1/agents//sandboxes/.\nRequires confirmation because the running sandbox cannot be recovered.', operationId: 'deleteAgentSandbox', tags: ['Agent Sandboxes'], security: [{ bearerAuth: [] }], @@ -261,7 +271,7 @@ export const agentSandboxRoutes = new Elysia() response: ApiUtils.mapResponseModel(z.undefined()), detail: { summary: 'Delete Custom Tag data', - description: 'Delete all stored data of one Custom Tag across the Agent\'s PER_CUSTOM_TAG volumes. Rejected while a sandbox with that tag exists.', + description: 'Delete all stored data of one Custom Tag across the Agent\'s PER_CUSTOM_TAG volumes. Rejected while a sandbox with that tag exists. No request body.\nExample: DELETE /api/v1/agents//tags/.\nRequires confirmation because this cannot be undone.', operationId: 'deleteAgentTag', tags: ['Agent Sandboxes'], security: [{ bearerAuth: [] }], @@ -280,7 +290,9 @@ export const agentSandboxRoutes = new Elysia() response: ApiUtils.mapResponseModel(commandResultZodModel), detail: { summary: 'Run command in agent sandbox', - description: 'Run a shell command in a running Agent Sandbox. CLI harness templates stay alive with `sleep infinity`; invoke their documented launcher or bootstrap script here so it receives the Agent Runtime Secret and selected LiteLLM Model Alias.', + description: `Run a shell command in a running Agent Sandbox. CLI harness templates stay alive with \`sleep infinity\`; invoke their documented launcher or bootstrap script here so it receives the Agent Runtime Secret and selected LiteLLM Model Alias. +Example: {"command":"pwd && ls -la","cwd":"/workspace","timeoutSec":120,"env":{"FOO":"bar"}} +timeoutSec is 1-3600 (default 120). Returns stdout, stderr and exitCode. Requires confirmation because the command can modify sandbox state.`, operationId: 'runAgentSandboxCommand', tags: ['Agent Sandboxes'], security: [{ bearerAuth: [] }], diff --git a/src/server/api/v1/app/deploy/route.ts b/src/server/api/v1/app/deploy/route.ts index 40df8a23..96c5890a 100644 --- a/src/server/api/v1/app/deploy/route.ts +++ b/src/server/api/v1/app/deploy/route.ts @@ -11,10 +11,11 @@ import { ApiNotFoundException, ApiUnauthorizedException } from '@/shared/model/s import { appDeploymentLogsResponseZodModel } from '@/shared/model/app-tail-log-entry'; import { deploymentDetailsResponseZodModel } from '@/shared/model/deployment-info.model'; import { ApiUtils } from '@/server/utils/api-response.utils'; +import paginationUtils, { paginationQueryZodModel } from '@/shared/utils/pagination.utils'; export const appDeployRoutes = new Elysia() .derive(ApiUtils.deriveFunc) - .get('/apps/:appId/deploy', async ({ params, identity }) => { + .get('/apps/:appId/deploy', async ({ params, query, identity }) => { if (!identity) throw new ApiUnauthorizedException() const app = await appService.getByIdOrUndefined(params.appId); @@ -22,17 +23,28 @@ export const appDeployRoutes = new Elysia() ensureReadApp(identity, app.id); - const deplyoments = await deploymentService.getDeploymentHistory(app.projectId, app.id); - return deplyoments.map(deployment => ({ - ...deployment, - appId: app.id, - })); + const deplyoments = (await deploymentService.getDeploymentHistory(app.projectId, app.id)) + .map(deployment => ({ + ...deployment, + appId: app.id, + })); + + return paginationUtils.isRequested(query) + ? paginationUtils.page(deplyoments, query) + : deplyoments; }, { params: z.object({ appId: z.string() }), - response: ApiUtils.mapResponseModel(deploymentDetailsResponseZodModel.array()), - detail: { summary: 'List app deployments', operationId: 'listAppDeployments', tags: ['Apps'], security: [{ bearerAuth: [] }] } + query: paginationQueryZodModel, + response: ApiUtils.mapResponseModel(paginationUtils.responseSchema(deploymentDetailsResponseZodModel)), + detail: { + summary: 'List app deployments', + description: 'Returns the deployment history as an array. Set limit (and optionally cursor) for the paginated envelope. Poll this after deployApp until the newest entry reports success or failure.', + operationId: 'listAppDeployments', + tags: ['Apps'], + security: [{ bearerAuth: [] }], + } }) .get('/apps/:appId/deploy/:deployemtId', async ({ params, identity }) => { if (!identity) throw new ApiUnauthorizedException() @@ -84,7 +96,13 @@ export const appDeployRoutes = new Elysia() tailLines: z.coerce.number().int().positive().max(5000).optional(), }), response: ApiUtils.mapResponseModel(appDeploymentLogsResponseZodModel), - detail: { summary: 'Get app deployment logs', operationId: 'getAppDeploymentLogs', tags: ['Apps'], security: [{ bearerAuth: [] }] } + detail: { + summary: 'Get app deployment logs', + description: 'Returns build/deploy logs for one deployment. Keep tailLines small (max 5000); large responses are truncated by MCP clients.', + operationId: 'getAppDeploymentLogs', + tags: ['Apps'], + security: [{ bearerAuth: [] }], + } }) .post('/apps/:appId/deploy', async ({ params, identity }) => { if (!identity) throw new ApiUnauthorizedException() @@ -102,5 +120,13 @@ export const appDeployRoutes = new Elysia() forceRebuild: z.coerce.boolean().optional().default(false), }), response: ApiUtils.mapResponseModel(z.object({ deploymentId: z.string() })), - detail: { summary: 'Deploy app', operationId: 'deployApp', tags: ['Apps'], security: [{ bearerAuth: [] }] } + detail: { + summary: 'Deploy app', + description: `Starts a build and rollout and returns {"deploymentId":"..."} immediately. Pass query.forceRebuild=true to rebuild even when the commit/image did not change. +Example: POST /api/v1/apps//deploy?forceRebuild=true. +Poll listAppDeployments or getAppDeployment with that deploymentId until the status is successful or failed. On failure, call getAppDeploymentLogs to inspect the build logs. This changes live Kubernetes workloads; ask for confirmation first.`, + operationId: 'deployApp', + tags: ['Apps'], + security: [{ bearerAuth: [] }] + } }); diff --git a/src/server/api/v1/app/route.ts b/src/server/api/v1/app/route.ts index 79caec1c..8f04a541 100644 --- a/src/server/api/v1/app/route.ts +++ b/src/server/api/v1/app/route.ts @@ -16,6 +16,7 @@ import { ApiUtils } from '../../../utils/api-response.utils'; import { ApiNotFoundException, ApiUnauthorizedException, ServiceException } from '@/shared/model/service.exception.model'; import { appLogsResponseZodModel } from '@/shared/model/app-tail-log-entry'; import { commandRequestZodModel, commandResultZodModel } from '@/shared/model/agent-sandbox.model'; +import paginationUtils, { paginationQueryZodModel } from '@/shared/utils/pagination.utils'; function stripAppSubObjectIdsForCreate(body: AppExtendedWriteModel): AppExtendedWriteModel { return { @@ -42,19 +43,27 @@ export const appRoutes = new Elysia() const apps = query.projectId ? await appService.getAllAppsByProjectId(query.projectId) : await appService.getAll(); - if (UserGroupUtils.isAdmin(identity.session)) { - return apps; - } + const visibleApps = UserGroupUtils.isAdmin(identity.session) + ? apps + : apps.filter(app => + UserGroupUtils.sessionHasReadAccessForApp(identity.session, app.id) + ); - return apps.filter(app => - UserGroupUtils.sessionHasReadAccessForApp(identity.session, app.id) - ); + return paginationUtils.isRequested(query) + ? paginationUtils.page(visibleApps, query) + : visibleApps; }, { - query: z.object({ + query: paginationQueryZodModel.extend({ projectId: z.string().optional(), }), - response: ApiUtils.mapResponseModel(z.array(AppExtendedZodModel)), - detail: { summary: 'List apps', operationId: 'listApps', tags: ['Apps'], security: [{ bearerAuth: [] }] } + response: ApiUtils.mapResponseModel(paginationUtils.responseSchema(AppExtendedZodModel)), + detail: { + summary: 'List apps', + description: 'Returns all apps as an array. Set limit (and optionally cursor) to receive the paginated envelope { items, total, limit, nextCursor } instead.', + operationId: 'listApps', + tags: ['Apps'], + security: [{ bearerAuth: [] }], + } }) .get('/apps/:appId', async ({ params, identity }) => { if (!identity) throw new ApiUnauthorizedException() @@ -94,7 +103,13 @@ export const appRoutes = new Elysia() lines: z.coerce.number().int().positive().max(5000).optional().default(200) }), response: ApiUtils.mapResponseModel(appLogsResponseZodModel), - detail: { summary: 'Get current app logs', operationId: 'getAppLogs', tags: ['Apps'], security: [{ bearerAuth: [] }] } + detail: { + summary: 'Get current app logs', + description: 'Returns the current log tail of the app. Keep `lines` small (default 200, max 5000); large responses are truncated by MCP clients.', + operationId: 'getAppLogs', + tags: ['Apps'], + security: [{ bearerAuth: [] }], + } }) .post('/apps/:appId/commands', async ({ params, body, identity }) => { if (!identity) throw new ApiUnauthorizedException(); @@ -113,7 +128,9 @@ export const appRoutes = new Elysia() response: ApiUtils.mapResponseModel(commandResultZodModel), detail: { summary: 'Run command in app', - description: 'Run a shell command in the first running Pod of this app. Requires write access to the app.', + description: `Run a shell command in the first running Pod of this app. Requires write access to the app. +Example: {"command":"ls -la /app","cwd":"/app","timeoutSec":120} +Returns stdout, stderr and exitCode. The command is executed inside the live Pod and can modify application data.`, operationId: 'runAppCommand', tags: ['Apps'], security: [{ bearerAuth: [] }], @@ -146,7 +163,16 @@ export const appRoutes = new Elysia() response: ApiUtils.mapResponseModel(AppExtendedZodModel), detail: { summary: 'Create or update app.', - description: 'When an ID is set, the app will be updated. Otherwise a new one will be created.', + description: `Upsert one App. +- Omit id to create: projectId is required. appDomains, appNodePorts, appFileMounts, appVolumes and appBasicAuths are required arrays (empty allowed), and appNetworkPolicy must be null or an object. +- Send id to update: projectId must equal the existing app's projectId and cannot be changed. The request replaces the stored app, so omitted or empty sub-resource arrays delete the existing entries. Call getApp first, then send the full object back with the ids of sub-resources you want to keep. +- This only saves desired state. Call deployApp afterwards to roll it out. Saving revalidates server caches but does not change Kubernetes resources. + +Example create: +{"name":"web","appType":"APP","projectId":"","sourceType":"CONTAINER","buildMethod":"RAILPACK","containerImageSource":"nginx:1.27","dockerfilePath":"./Dockerfile","replicas":1,"envVars":"","buildArgs":"","useNetworkPolicy":true,"healthCheckPeriodSeconds":15,"healthCheckTimeoutSeconds":5,"healthCheckFailureThreshold":3,"appDomains":[{"hostname":"web.example.com","port":80,"useSsl":true,"redirectHttps":true}],"appNodePorts":[],"appFileMounts":[],"appVolumes":[],"appBasicAuths":[],"appNetworkPolicy":null} + +Example update (read getApp first and keep all sub-resource ids you do not want to delete): +{"id":"","name":"web","appType":"APP","projectId":"","sourceType":"CONTAINER","buildMethod":"DOCKERFILE","containerImageSource":"nginx:1.27","dockerfilePath":"./Dockerfile","replicas":2,"envVars":"","buildArgs":"","useNetworkPolicy":true,"healthCheckPeriodSeconds":15,"healthCheckTimeoutSeconds":5,"healthCheckFailureThreshold":3,"appDomains":[{"id":"","hostname":"web.example.com","port":80,"useSsl":true,"redirectHttps":true}],"appNodePorts":[],"appFileMounts":[],"appVolumes":[],"appBasicAuths":[],"appNetworkPolicy":null}`, operationId: 'saveApp', tags: ['Apps'], security: [{ bearerAuth: [] }] @@ -167,5 +193,11 @@ export const appRoutes = new Elysia() id: z.string(), }), response: ApiUtils.mapResponseModel(z.undefined()), - detail: { summary: 'Delete app', operationId: 'deleteApp', tags: ['Apps'], security: [{ bearerAuth: [] }] } + detail: { + summary: 'Delete app', + description: 'Permanently deletes the app and its sub-resources (domains, volumes, file mounts, basic auth). No request body.\nExample: DELETE /api/v1/apps/.\nRequires confirmation because this cannot be undone.', + operationId: 'deleteApp', + tags: ['Apps'], + security: [{ bearerAuth: [] }], + } }); diff --git a/src/server/api/v1/project/route.ts b/src/server/api/v1/project/route.ts index 385dd934..65e15858 100644 --- a/src/server/api/v1/project/route.ts +++ b/src/server/api/v1/project/route.ts @@ -7,6 +7,7 @@ import { ProjectModel } from '@/shared/model/generated-zod'; import { ApiUtils } from '../../../utils/api-response.utils'; import { Project } from '@prisma/client'; import { ApiNotFoundException, ApiUnauthorizedException } from '@/shared/model/service.exception.model'; +import paginationUtils, { paginationQueryZodModel } from '@/shared/utils/pagination.utils'; const projectWriteSchema = ProjectModel .omit({ createdAt: true, updatedAt: true }) @@ -16,21 +17,30 @@ const projectWriteSchema = ProjectModel export const projectRoutes = new Elysia() .derive(ApiUtils.deriveFunc) - .get('/projects', async ({ identity }) => { + .get('/projects', async ({ query, identity }) => { if (!identity) throw new ApiUnauthorizedException() const projects = await projectService.getAll(); - if (UserGroupUtils.isAdmin(identity.session)) { - return projects; - } + const visibleProjects = UserGroupUtils.isAdmin(identity.session) + ? projects + : projects.filter(project => + UserGroupUtils.sessionHasReadAccessToProject(identity.session, project.id) + ); - return projects.filter(project => - UserGroupUtils.sessionHasReadAccessToProject(identity.session, project.id) - ); + return paginationUtils.isRequested(query) + ? paginationUtils.page(visibleProjects, query) + : visibleProjects; }, { - response: ApiUtils.mapResponseModel(z.array(ProjectModel)), - detail: { summary: 'List projects', operationId: 'listProjects', tags: ['Projects'], security: [{ bearerAuth: [] }] } + query: paginationQueryZodModel, + response: ApiUtils.mapResponseModel(paginationUtils.responseSchema(ProjectModel)), + detail: { + summary: 'List projects', + description: 'Returns all projects as an array. Set limit (and optionally cursor) to receive the paginated envelope { items, total, limit, nextCursor } instead.', + operationId: 'listProjects', + tags: ['Projects'], + security: [{ bearerAuth: [] }], + } }) .get('/projects/:id', async ({ params, identity }) => { @@ -66,7 +76,16 @@ export const projectRoutes = new Elysia() }, { body: projectWriteSchema, response: ApiUtils.mapResponseModel(ProjectModel), - detail: { summary: 'Create or update project', operationId: 'saveProject', tags: ['Projects'], security: [{ bearerAuth: [] }] } + detail: { + summary: 'Create or update project', + description: `Upsert one project. Omit id to create; send id to update an existing project. +Example create: {"name":"team-demo"} +Example update: {"id":"","name":"team-demo-renamed"} +Requires admin. Every project maps to a Kubernetes namespace; saveProject only stores metadata, deployApp or deployAgent creates the namespace.`, + operationId: 'saveProject', + tags: ['Projects'], + security: [{ bearerAuth: [] }], + } }) .delete('/projects/:id', async ({ params, identity }) => { if (!identity) throw new ApiUnauthorizedException() @@ -82,5 +101,11 @@ export const projectRoutes = new Elysia() id: z.string(), }), response: ApiUtils.mapResponseModel(z.undefined()), - detail: { summary: 'Delete project', operationId: 'deleteProject', tags: ['Projects'], security: [{ bearerAuth: [] }] } + detail: { + summary: 'Delete project', + description: 'Permanently deletes the project, all its Apps and Agents, and the Kubernetes namespace with their workloads and volumes. No request body.\nExample: DELETE /api/v1/projects/.\nRequires admin and confirmation because this cannot be undone.', + operationId: 'deleteProject', + tags: ['Projects'], + security: [{ bearerAuth: [] }], + } }); diff --git a/src/server/mcp/mcp-errors.ts b/src/server/mcp/mcp-errors.ts new file mode 100644 index 00000000..771b35e0 --- /dev/null +++ b/src/server/mcp/mcp-errors.ts @@ -0,0 +1,32 @@ +/** + * JSON-RPC error codes emitted by the QuickStack MCP endpoint. + * + * The MCP specification (2026-07-28) reserves -32768..-32000 for JSON-RPC and + * MCP-defined errors. Application-defined codes belong outside that range. + * The deprecated MCP code -32002 must not be emitted anymore. + */ +export const MCP_ERROR_CODES = { + SERVER_DISABLED: 1001, + UNAUTHORIZED: 1002, +} as const; + +export type McpErrorCode = typeof MCP_ERROR_CODES[keyof typeof MCP_ERROR_CODES]; + +export function jsonRpcErrorResponse(options: { + code: McpErrorCode; + message: string; + status: number; + headers?: Record; +}): Response { + const { code, message, status, headers } = options; + return new Response( + JSON.stringify({ jsonrpc: '2.0', id: null, error: { code, message } }), + { + status, + headers: { + 'content-type': 'application/json', + ...headers, + }, + } + ); +} diff --git a/src/server/mcp/mcp-handler.ts b/src/server/mcp/mcp-handler.ts index ca4f9571..6de3b5ee 100644 --- a/src/server/mcp/mcp-handler.ts +++ b/src/server/mcp/mcp-handler.ts @@ -5,6 +5,7 @@ import { } from '@modelcontextprotocol/server'; import type { AuthInfo } from '@modelcontextprotocol/server'; import type { McpAuthResolver } from './mcp-auth'; +import { jsonRpcErrorResponse, MCP_ERROR_CODES } from './mcp-errors'; import mcpServerFactory from './mcp-server.factory'; import operationRegistry from './operation-registry'; import type { McpSourceApp, OperationDescriptor } from './operation-registry'; @@ -23,16 +24,12 @@ export type QuickStackMcpHandler = { }; function unauthorizedResponse(): Response { - return new Response( - JSON.stringify({ jsonrpc: '2.0', id: null, error: { code: -32001, message: 'Unauthorized' } }), - { - status: 401, - headers: { - 'content-type': 'application/json', - 'www-authenticate': 'Bearer', - }, - } - ); + return jsonRpcErrorResponse({ + code: MCP_ERROR_CODES.UNAUTHORIZED, + message: 'Unauthorized', + status: 401, + headers: { 'www-authenticate': 'Bearer' }, + }); } export function createQuickStackMcpHandler(options: QuickStackMcpHandlerOptions): QuickStackMcpHandler { diff --git a/src/server/mcp/mcp-handler.unit.spec.ts b/src/server/mcp/mcp-handler.unit.spec.ts index 0ad951ea..f47e91b1 100644 --- a/src/server/mcp/mcp-handler.unit.spec.ts +++ b/src/server/mcp/mcp-handler.unit.spec.ts @@ -31,6 +31,13 @@ function buildFixtureApp() { response: { 200: z.object({ id: z.string(), search: z.string().nullable() }), 401: unauthorizedSchema }, detail: { operationId: 'getProject', summary: 'Get project', tags: ['Projects'] }, }) + .get('/project-activity', ({ authenticated }) => { + if (!authenticated) return unauthorized(); + return { activity: [] }; + }, { + response: { 200: z.object({ activity: z.array(z.string()) }), 401: unauthorizedSchema }, + detail: { operationId: 'getProjectActivity', summary: 'Get project activity', tags: ['Activity'] }, + }) .post('/projects', ({ body, authenticated }) => { if (!authenticated) return unauthorized(); return { id: 'p2', name: body.name }; @@ -39,6 +46,34 @@ function buildFixtureApp() { response: { 200: z.object({ id: z.string(), name: z.string() }), 401: unauthorizedSchema }, detail: { operationId: 'saveProject', summary: 'Save project', tags: ['Projects'] }, }) + .post('/apps/:appId/deploy', ({ params, authenticated }) => { + if (!authenticated) return unauthorized(); + return { deploymentId: `deploy-${params.appId}` }; + }, { + params: z.object({ appId: z.string() }), + response: { 200: z.object({ deploymentId: z.string() }), 401: unauthorizedSchema }, + detail: { operationId: 'deployApp', summary: 'Deploy app', description: 'Starts a build and rollout for the app.', tags: ['Apps'] }, + }) + .get('/huge', () => ({ blob: 'x'.repeat(100_000) }), { + response: { 200: z.object({ blob: z.string() }) }, + detail: { operationId: 'getHugePayload', summary: 'Get huge payload', tags: ['Testing'] }, + }) + .get('/field-error', () => new Response(JSON.stringify({ + type: 'about:blank', + title: 'Unprocessable Entity', + status: 422, + detail: 'Request validation failed.', + errors: [ + { path: 'name', message: 'Too small: expected string to have >=3 characters' }, + { path: 'appDomains.0.hostname', message: 'Invalid hostname' }, + ], + }), { + status: 422, + headers: { 'content-type': 'application/problem+json' }, + }), { + response: { 200: z.object({ ok: z.boolean() }), 422: z.object({ title: z.string(), errors: z.array(z.object({ path: z.string(), message: z.string() })) }) }, + detail: { operationId: 'getFieldErrors', summary: 'Get field errors', tags: ['Testing'] }, + }) .put('/agents/:agentId/sandboxes/:sandboxName/files/write', ({ body, authenticated }) => { if (!authenticated) return unauthorized(); return { path: body.path }; @@ -132,7 +167,20 @@ describe('QuickStack MCP handler', () => { expect(status).toBe(200); const names = body.result.tools.map((tool: { name: string }) => tool.name).sort(); - expect(names).toEqual(['execute_operation', 'execute_read_operation', 'search_operations']); + expect(names).toEqual([ + 'quickstack_execute_operation', + 'quickstack_execute_read_operation', + 'quickstack_search_operations', + ]); + }); + + it('publishes server instructions that describe the workflow', async () => { + const response = await buildHandler().fetch(jsonRpcRequest({ method: 'server/discover' })); + const body = await response.json(); + + expect(response.status).toBe(200); + expect(body.result.instructions).toContain('quickstack_search_operations'); + expect(body.result.instructions).toContain('ask the user for confirmation'); }); it('rejects a request with an invalid REST API Key', async () => { @@ -142,7 +190,10 @@ describe('QuickStack MCP handler', () => { it('rejects a request with no credential', async () => { const response = await buildHandler().fetch(jsonRpcRequest({ method: 'tools/list', token: null })); + const body = await response.json(); + expect(response.status).toBe(401); + expect(body.error).toEqual({ code: 1002, message: 'Unauthorized' }); }); it('rejects a cross-origin browser request', async () => { @@ -156,7 +207,7 @@ describe('QuickStack MCP handler', () => { }); it('derives operations from the routes in deterministic id order', async () => { - const { body } = await callTool(buildHandler(), 'search_operations', {}); + const { body } = await callTool(buildHandler(), 'quickstack_search_operations', {}); const operations = body.result.structuredContent.operations as Array<{ operationId: string }>; const ids = operations.map((operation) => operation.operationId); @@ -166,15 +217,15 @@ describe('QuickStack MCP handler', () => { }); it('filters operations by search term', async () => { - const { body } = await callTool(buildHandler(), 'search_operations', { search: 'log' }); + const { body } = await callTool(buildHandler(), 'quickstack_search_operations', { search: 'log' }); const operations = body.result.structuredContent.operations as Array<{ operationId: string }>; expect(operations.map((operation) => operation.operationId)).toEqual(['streamLogs']); }); it('returns schemas only at the full detail level', async () => { - const summary = await callTool(buildHandler(), 'search_operations', { search: 'getProject', detail: 'summary' }); - const full = await callTool(buildHandler(), 'search_operations', { search: 'getProject', detail: 'full' }); + const summary = await callTool(buildHandler(), 'quickstack_search_operations', { search: 'getProject', detail: 'summary' }); + const full = await callTool(buildHandler(), 'quickstack_search_operations', { search: 'getProject', detail: 'full' }); const summaryOperation = summary.body.result.structuredContent.operations[0]; const fullOperation = full.body.result.structuredContent.operations[0]; @@ -185,7 +236,7 @@ describe('QuickStack MCP handler', () => { }); it('marks excluded, streaming, and manually parsed operations as unsupported', async () => { - const { body } = await callTool(buildHandler(), 'search_operations', { detail: 'full' }); + const { body } = await callTool(buildHandler(), 'quickstack_search_operations', { detail: 'summary' }); const operations = body.result.structuredContent.operations as Array<{ operationId: string; supported: boolean; @@ -197,34 +248,37 @@ describe('QuickStack MCP handler', () => { const uploadFile = operations.find((operation) => operation.operationId === 'uploadFile'); const listProjects = operations.find((operation) => operation.operationId === 'listProjects'); - expect(writeAgentSandboxFile).toMatchObject({ - supported: false, - unsupportedReason: 'Operation is excluded from MCP.', - }); + expect(writeAgentSandboxFile?.supported).toBe(false); expect(streamLogs?.supported).toBe(false); expect(uploadFile?.supported).toBe(false); expect(listProjects?.supported).toBe(true); + + const full = await callTool(buildHandler(), 'quickstack_search_operations', { + search: 'writeAgentSandboxFile', + detail: 'full', + }); + expect(full.body.result.structuredContent.operations[0].unsupportedReason).toBe('Operation is excluded from MCP.'); }); it('reports readOnly and the matching tool for each operation', async () => { - const { body } = await callTool(buildHandler(), 'search_operations', { detail: 'full' }); + const { body } = await callTool(buildHandler(), 'quickstack_search_operations', { detail: 'summary' }); const operations = body.result.structuredContent.operations as Array<{ operationId: string; readOnly: boolean; tool: string }>; const byId = new Map(operations.map((operation) => [operation.operationId, operation])); - expect(byId.get('listProjects')).toMatchObject({ readOnly: true, tool: 'execute_read_operation' }); - expect(byId.get('getProject')).toMatchObject({ readOnly: true, tool: 'execute_read_operation' }); - expect(byId.get('saveProject')).toMatchObject({ readOnly: false, tool: 'execute_operation' }); + expect(byId.get('listProjects')).toMatchObject({ readOnly: true, tool: 'quickstack_execute_read_operation' }); + expect(byId.get('getProject')).toMatchObject({ readOnly: true, tool: 'quickstack_execute_read_operation' }); + expect(byId.get('saveProject')).toMatchObject({ readOnly: false, tool: 'quickstack_execute_operation' }); }); it('executes a read operation and returns the REST payload', async () => { - const { body } = await callTool(buildHandler(), 'execute_read_operation', { operationId: 'listProjects' }); + const { body } = await callTool(buildHandler(), 'quickstack_execute_read_operation', { operationId: 'listProjects' }); expect(body.result.isError).not.toBe(true); expect(body.result.structuredContent.result).toEqual([{ id: 'p1', name: 'Demo' }]); }); it('maps path params and query arguments onto the REST route', async () => { - const { body } = await callTool(buildHandler(), 'execute_read_operation', { + const { body } = await callTool(buildHandler(), 'quickstack_execute_read_operation', { operationId: 'getProject', pathParams: { id: 'p-42' }, query: { search: 'needle' }, @@ -234,7 +288,7 @@ describe('QuickStack MCP handler', () => { }); it('sends the request body for write operations', async () => { - const { body } = await callTool(buildHandler(), 'execute_operation', { + const { body } = await callTool(buildHandler(), 'quickstack_execute_operation', { operationId: 'saveProject', body: { name: 'New project' }, }); @@ -243,35 +297,35 @@ describe('QuickStack MCP handler', () => { }); it('forwards the bearer credential to the REST route', async () => { - const { body } = await callTool(buildHandler(), 'execute_read_operation', { operationId: 'listProjects' }); + const { body } = await callTool(buildHandler(), 'quickstack_execute_read_operation', { operationId: 'listProjects' }); expect(body.result.isError).not.toBe(true); expect(body.result.structuredContent.result).toHaveLength(1); }); it('rejects a mutating operation on the read tool', async () => { - const { body } = await callTool(buildHandler(), 'execute_read_operation', { operationId: 'saveProject', body: { name: 'x' } }); + const { body } = await callTool(buildHandler(), 'quickstack_execute_read_operation', { operationId: 'saveProject', body: { name: 'x' } }); expect(body.result.isError).toBe(true); - expect(body.result.content[0].text).toContain('execute_operation'); + expect(body.result.content[0].text).toContain('quickstack_execute_operation'); }); it('rejects a read-only operation on the write tool', async () => { - const { body } = await callTool(buildHandler(), 'execute_operation', { operationId: 'listProjects' }); + const { body } = await callTool(buildHandler(), 'quickstack_execute_operation', { operationId: 'listProjects' }); expect(body.result.isError).toBe(true); - expect(body.result.content[0].text).toContain('execute_read_operation'); + expect(body.result.content[0].text).toContain('quickstack_execute_read_operation'); }); it('returns a tool error for an unknown operation id', async () => { - const { body } = await callTool(buildHandler(), 'execute_operation', { operationId: 'doesNotExist' }); + const { body } = await callTool(buildHandler(), 'quickstack_execute_operation', { operationId: 'doesNotExist' }); expect(body.result.isError).toBe(true); expect(body.result.content[0].text).toContain('doesNotExist'); }); it('returns a tool error for an unsupported operation', async () => { - const { body } = await callTool(buildHandler(), 'execute_read_operation', { operationId: 'streamLogs' }); + const { body } = await callTool(buildHandler(), 'quickstack_execute_read_operation', { operationId: 'streamLogs' }); expect(body.result.isError).toBe(true); }); @@ -283,9 +337,62 @@ describe('QuickStack MCP handler', () => { resolveAuthInfo: async () => ({ token: 'wrong', clientId: 'user-1', scopes: [] }), }); - const { body } = await callTool(handler, 'execute_read_operation', { operationId: 'listProjects' }); + const { body } = await callTool(handler, 'quickstack_execute_read_operation', { operationId: 'listProjects' }); expect(body.result.isError).toBe(true); expect(body.result.content[0].text).toContain('401'); }); + + it('requires a search term for the full detail level', async () => { + const { body } = await callTool(buildHandler(), 'quickstack_search_operations', { detail: 'full' }); + + expect(body.result.isError).toBe(true); + expect(body.result.content[0].text).toContain('requires a search term'); + }); + + it('combines multiple search words with AND', async () => { + const matching = await callTool(buildHandler(), 'quickstack_search_operations', { search: 'sandbox file' }); + const incompatible = await callTool(buildHandler(), 'quickstack_search_operations', { search: 'project deploy' }); + + const ids = matching.body.result.structuredContent.operations.map((operation: { operationId: string }) => operation.operationId); + expect(ids).toEqual(['writeAgentSandboxFile']); + expect(incompatible.body.result.structuredContent.operations).toEqual([]); + expect(incompatible.body.result.structuredContent.total).toBe(0); + }); + + it('ranks operation id matches above description-only matches', async () => { + const { body } = await callTool(buildHandler(), 'quickstack_search_operations', { search: 'project', detail: 'name' }); + const ids = body.result.structuredContent.operations.map((operation: { operationId: string }) => operation.operationId); + + expect(ids.indexOf('listProjects')).toBeGreaterThanOrEqual(0); + expect(ids.indexOf('listProjects')).toBeLessThan(ids.indexOf('getProjectActivity')); + }); + + it('limits search results and hints at the remaining matches', async () => { + const { body } = await callTool(buildHandler(), 'quickstack_search_operations', { search: 'project', limit: 1 }); + const structured = body.result.structuredContent; + + expect(structured.returned).toBe(1); + expect(structured.total).toBeGreaterThan(1); + expect(structured.hint).toContain('Showing 1 of'); + }); + + it('truncates oversized operation results with a narrowing hint', async () => { + const { body } = await callTool(buildHandler(), 'quickstack_execute_read_operation', { operationId: 'getHugePayload' }); + const structured = body.result.structuredContent; + + expect(structured.truncated).toBe(true); + expect(structured.originalCharacters).toBeGreaterThan(100_000); + expect(body.result.content[0].text).toContain('Response truncated'); + expect(structured.hint).toContain('Narrow the request'); + }); + + it('passes REST field validation errors through to the tool error', async () => { + const { body } = await callTool(buildHandler(), 'quickstack_execute_read_operation', { operationId: 'getFieldErrors' }); + + expect(body.result.isError).toBe(true); + expect(body.result.content[0].text).toContain('QuickStack returned HTTP 422'); + expect(body.result.content[0].text).toContain('name: Too small'); + expect(body.result.content[0].text).toContain('appDomains.0.hostname: Invalid hostname'); + }); }); diff --git a/src/server/mcp/mcp-server.factory.ts b/src/server/mcp/mcp-server.factory.ts index 1c0b54cd..b428315d 100644 --- a/src/server/mcp/mcp-server.factory.ts +++ b/src/server/mcp/mcp-server.factory.ts @@ -1,15 +1,40 @@ import { McpServer } from '@modelcontextprotocol/server'; import type { AuthInfo } from '@modelcontextprotocol/server'; import { z } from 'zod'; +import packageJson from '../../../package.json'; import operationExecutor from './operation-executor'; import type { McpSourceApp, OperationDescriptor } from './operation-registry'; -const READ_TOOL = 'execute_read_operation'; -const WRITE_TOOL = 'execute_operation'; +export const MCP_TOOL_NAMES = { + search: 'quickstack_search_operations', + read: 'quickstack_execute_read_operation', + write: 'quickstack_execute_operation', +} as const; + +const SEARCH_TOOL = MCP_TOOL_NAMES.search; +const READ_TOOL = MCP_TOOL_NAMES.read; +const WRITE_TOOL = MCP_TOOL_NAMES.write; + +const SERVER_NAME = 'quickstack'; +const SERVER_VERSION = packageJson.version; + +const DEFAULT_SEARCH_LIMIT = 25; +const MAX_SEARCH_LIMIT = 100; + +/** Keeps one tool result well below Anthropic's 25k-token tool-response ceiling. */ +const MAX_OPERATION_RESULT_CHARS = 48_000; +const MAX_ERROR_CHARS = 4_000; + +const SEARCH_STOPWORDS = new Set([ + 'a', 'an', 'and', 'are', 'can', 'could', 'do', 'does', 'for', 'from', 'how', + 'i', 'in', 'is', 'it', 'me', 'my', 'of', 'on', 'or', 'please', 'show', 'the', + 'to', 'what', 'which', 'with', 'would', 'you', +]); const searchInputSchema = z.object({ - search: z.string().optional().describe('Free-text match against operation id, summary, description and tags.'), - detail: z.enum(['name', 'summary', 'full']).optional().default('summary').describe('How much of each operation to return.'), + search: z.string().optional().describe('Free-text match. Words are combined with AND against operation id, summary, description and tags.'), + detail: z.enum(['name', 'summary', 'full']).optional().default('summary').describe('How much of each operation to return. "full" requires a search term.'), + limit: z.number().int().min(1).max(MAX_SEARCH_LIMIT).optional().default(DEFAULT_SEARCH_LIMIT).describe('Maximum number of matching operations to return.'), }); const operationSummarySchema = z.object({ @@ -34,44 +59,60 @@ const operationFullSchema = operationSummarySchema.extend({ const searchOutputSchema = z.object({ operations: z.array(operationFullSchema), + total: z.number().int(), + returned: z.number().int(), + hint: z.string().optional(), }); const readInputSchema = z.object({ - operationId: z.string().describe('Operation id returned by search_operations.'), + operationId: z.string().describe(`Operation id returned by ${SEARCH_TOOL}.`), pathParams: z.record(z.string(), z.unknown()).optional().default({}).describe('Values for the :placeholders in the operation path.'), - query: z.record(z.string(), z.unknown()).optional().default({}).describe('Query string parameters.'), + query: z.record(z.string(), z.unknown()).optional().default({}).describe('Query string parameters. List operations accept limit and cursor.'), }); const writeInputSchema = readInputSchema.extend({ - body: z.unknown().optional().describe('JSON request body for write operations.'), + body: z.unknown().optional().describe('JSON request body for write operations. Inspect bodySchema first.'), }); const executeOutputSchema = z.object({ status: z.number().int(), result: z.unknown(), + truncated: z.boolean().optional(), + originalCharacters: z.number().int().optional(), + hint: z.string().optional(), }); +const SERVER_INSTRUCTIONS = [ + 'QuickStack is a self-hosted platform for deploying Apps and Agents on Kubernetes.', + `Workflow: call ${SEARCH_TOOL} to find the operation id, call it again with detail "full" to read the exact input schema, then run the operation with ${READ_TOOL} or ${WRITE_TOOL}.`, + `Only pass a readOnly operation to ${READ_TOOL}. Use ${WRITE_TOOL} for deploys, deletes, saves and commands.`, + `Before calling ${WRITE_TOOL}, summarize the intended change and ask the user for confirmation. Never chain destructive writes without explicit approval.`, + 'deployApp returns a deploymentId immediately; poll listAppDeployments or getAppDeployment until it reports success or failure. deployAgent applies CONTAINER configs synchronously; GIT builds continue asynchronously in the QuickStack UI, so poll listAgentSandboxes before starting a sandbox.', + 'Tool responses are truncated when they exceed the size limit. Narrow the request with search filters, list limit/cursor or a smaller lines value.', +].join(' '); + const SEARCH_TOOL_DESCRIPTION = [ 'Search the QuickStack operations catalog.', 'QuickStack is a self-hosted platform for deploying Apps and Agents.', 'Use this tool first to discover the operation id for the action you need.', + 'Always start with detail "summary" to see the operation list, then repeat the search with detail "full" for the one operation whose schema you need.', 'Each operation reports readOnly and the tool to call:', `${READ_TOOL} for read-only operations and ${WRITE_TOOL} for mutating operations.`, - 'Use detail "full" only when you need the exact input and output schema.', + 'Search words are combined with AND. Prefer concrete terms such as "deploy app", "app logs" or "agent sandbox".', ].join(' '); const READ_TOOL_DESCRIPTION = [ 'Execute a read-only QuickStack operation.', - 'Call search_operations first and pass an operationId whose readOnly field is true.', + `Call ${SEARCH_TOOL} first and pass an operationId whose readOnly field is true.`, 'Map your arguments to pathParams and query according to the operation schema.', 'Read operations never change QuickStack state and need no confirmation.', ].join(' '); const WRITE_TOOL_DESCRIPTION = [ 'Execute a mutating QuickStack operation, such as create, update, delete, deploy or run a command.', - 'Call search_operations first and pass an operationId whose readOnly field is false.', - 'Map your arguments to pathParams, query and body according to the operation schema.', - 'This tool changes QuickStack state: ask the user for confirmation before calling it.', + `Workflow: call ${SEARCH_TOOL} with detail "full" for the operationId, map your arguments to pathParams, query and body, then call this tool.`, + 'The operation description contains request examples for each write operation.', + 'This tool changes QuickStack state: summarize the change and ask the user for confirmation before calling it.', ].join(' '); type OperationArguments = { @@ -81,16 +122,25 @@ type OperationArguments = { body?: unknown; }; +type RankedOperation = { + operation: OperationDescriptor; + score: number; +}; + export type McpServerBuildOptions = { app: McpSourceApp; operations: OperationDescriptor[]; authInfo?: AuthInfo; + version?: string; }; class McpServerFactory { build(options: McpServerBuildOptions): McpServer { - const server = new McpServer({ name: 'quickstack', version: '1.0.0' }); + const server = new McpServer( + { name: SERVER_NAME, version: options.version ?? SERVER_VERSION }, + { instructions: SERVER_INSTRUCTIONS } + ); this.registerSearchOperation(server, options.operations); this.registerReadOperation(server, options); this.registerWriteOperation(server, options); @@ -99,7 +149,7 @@ class McpServerFactory { private registerSearchOperation(server: McpServer, operations: OperationDescriptor[]): void { server.registerTool( - 'search_operations', + SEARCH_TOOL, { title: 'Search QuickStack operations', description: SEARCH_TOOL_DESCRIPTION, @@ -107,15 +157,29 @@ class McpServerFactory { outputSchema: searchOutputSchema, annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: false }, }, - async ({ search, detail }) => { - const term = search?.trim().toLowerCase() ?? ''; - const operationList = operations - .filter((operation) => this.matchesSearch(operation, term)) - .map((operation) => this.serializeOperation(operation, detail)); + async ({ search, detail, limit }) => { + const trimmedSearch = search?.trim() ?? ''; + if (detail === 'full' && !trimmedSearch) { + return this.errorResult( + `detail "full" requires a search term. Call ${SEARCH_TOOL} with search and detail "full" for the operation you need.` + ); + } + + const ranked = this.rankOperations(operations, trimmedSearch); + const page = ranked.slice(0, limit); + const operationList = page.map((entry) => this.serializeOperation(entry.operation, detail)); + const hint = ranked.length > page.length + ? `Showing ${page.length} of ${ranked.length} matches. Refine the search or raise limit (max ${MAX_SEARCH_LIMIT}).` + : undefined; return { - content: [{ type: 'text' as const, text: JSON.stringify({ operations: operationList }) }], - structuredContent: { operations: operationList }, + content: [{ type: 'text' as const, text: JSON.stringify({ operations: operationList, total: ranked.length, returned: operationList.length, hint }) }], + structuredContent: { + operations: operationList, + total: ranked.length, + returned: operationList.length, + hint, + }, }; } ); @@ -162,7 +226,7 @@ class McpServerFactory { const operation = operations.find((candidate) => candidate.operationId === args.operationId); if (!operation) { - return this.errorResult(`Unknown operation "${args.operationId}". Call search_operations to find valid operation ids.`); + return this.errorResult(`Unknown operation "${args.operationId}". Call ${SEARCH_TOOL} to find valid operation ids.`); } if (!operation.supported) { return this.errorResult(`Operation "${args.operationId}" is not available over MCP. ${operation.unsupportedReason ?? ''}`.trim()); @@ -187,14 +251,61 @@ class McpServerFactory { } if (execution.status >= 200 && execution.status < 300) { - const result = execution.result ?? null; + return this.successResult(operation, execution.status, execution.result ?? null); + } + + return this.errorResult(this.describeFailure(execution.status, execution.result)); + } + + private successResult(operation: OperationDescriptor, status: number, result: unknown) { + const serialized = JSON.stringify(result); + if (serialized.length <= MAX_OPERATION_RESULT_CHARS) { return { - content: [{ type: 'text' as const, text: JSON.stringify(result) }], - structuredContent: { status: execution.status, result }, + content: [{ type: 'text' as const, text: serialized }], + structuredContent: { status, result }, }; } - return this.errorResult(this.describeFailure(execution.status, execution.result)); + const hint = this.buildTruncationHint(operation); + const preview = serialized.slice(0, MAX_OPERATION_RESULT_CHARS); + return { + content: [{ + type: 'text' as const, + text: `${preview}\n\n[Response truncated: ${serialized.length} characters total, showing the first ${MAX_OPERATION_RESULT_CHARS}. ${hint}]`, + }], + structuredContent: { + status, + result: preview, + truncated: true, + originalCharacters: serialized.length, + hint, + }, + }; + } + + private buildTruncationHint(operation: OperationDescriptor): string { + const queryProperties = this.schemaProperties(operation.querySchema); + if (queryProperties.includes('lines')) { + return 'Retry with a smaller query.lines value to fetch fewer log lines.'; + } + if (queryProperties.includes('tailLines')) { + return 'Retry with a smaller query.tailLines value to fetch fewer log lines.'; + } + if (queryProperties.includes('limit') || queryProperties.includes('cursor')) { + return 'Retry with query.limit and, if present in the response, query.cursor to page through the result.'; + } + return 'Narrow the request with filters or fetch a single resource instead of a large collection.'; + } + + private schemaProperties(schema: Record | undefined): string[] { + if (!schema || typeof schema !== 'object') { + return []; + } + const properties = (schema as { properties?: unknown }).properties; + if (!properties || typeof properties !== 'object') { + return []; + } + return Object.keys(properties as Record); } private serializeOperation(operation: OperationDescriptor, detail: 'name' | 'summary' | 'full') { @@ -226,33 +337,139 @@ class McpServerFactory { }; } - private matchesSearch(operation: OperationDescriptor, term: string): boolean { - if (!term) { - return true; + private rankOperations(operations: OperationDescriptor[], search: string): RankedOperation[] { + if (!search) { + return operations.map((operation) => ({ operation, score: 0 })); + } + + const query = search.toLowerCase(); + const tokens = this.tokenizeSearch(query); + const ranked: RankedOperation[] = []; + + for (const operation of operations) { + const score = this.scoreOperation(operation, query, tokens); + if (score >= 0) { + ranked.push({ operation, score }); + } + } + + return ranked.sort((left, right) => + right.score - left.score || left.operation.operationId.localeCompare(right.operation.operationId) + ); + } + + private tokenizeSearch(query: string): string[] { + return query + .split(/[^a-z0-9]+/) + .filter((token) => token.length > 1 && !SEARCH_STOPWORDS.has(token)); + } + + private scoreOperation(operation: OperationDescriptor, query: string, tokens: string[]): number { + const operationId = operation.operationId.toLowerCase(); + const summary = (operation.summary ?? '').toLowerCase(); + const description = (operation.description ?? '').toLowerCase(); + const tags = operation.tags.map((tag) => tag.toLowerCase()); + + let score = 0; + for (const token of tokens) { + let tokenScore = 0; + if (operationId === token) { + tokenScore += 100; + } else if (operationId.includes(token)) { + tokenScore += 25; + } + if (tags.some((tag) => tag === token)) { + tokenScore += 20; + } else if (tags.some((tag) => tag.includes(token))) { + tokenScore += 10; + } + if (summary.includes(token)) { + tokenScore += 5; + } + if (description.includes(token)) { + tokenScore += 2; + } + if (tokenScore === 0) { + return -1; + } + score += tokenScore; + } + + if (operationId === query) { + score += 200; + } else if (operationId.startsWith(query)) { + score += 50; } - const haystack = [ - operation.operationId, - operation.summary, - operation.description, - ...operation.tags, - ].filter((value): value is string => Boolean(value)); - return haystack.some((value) => value.toLowerCase().includes(term)); + return score; } private errorResult(text: string) { - return { isError: true as const, content: [{ type: 'text' as const, text }] }; + const bounded = text.length > MAX_ERROR_CHARS + ? `${text.slice(0, MAX_ERROR_CHARS)}\n[Error message truncated]` + : text; + return { isError: true as const, content: [{ type: 'text' as const, text: bounded }] }; } private describeFailure(status: number, result: unknown): string { if (result && typeof result === 'object') { - const problem = result as { detail?: unknown; title?: unknown }; - const message = typeof problem.detail === 'string' ? problem.detail : problem.title; - if (typeof message === 'string') { - return `QuickStack returned HTTP ${status}: ${message}`; + const problem = result as Record; + const parts: string[] = []; + + if (typeof problem.detail === 'string' && problem.detail.trim()) { + parts.push(problem.detail); + } else if (typeof problem.title === 'string' && problem.title.trim()) { + parts.push(problem.title); + } + + for (const key of ['errors', 'issues']) { + const entries = problem[key]; + if (!Array.isArray(entries)) { + continue; + } + const formatted = entries + .map((entry) => this.formatValidationEntry(entry)) + .filter((entry): entry is string => Boolean(entry)); + if (formatted.length > 0) { + parts.push(formatted.join('; ')); + } + } + + if (parts.length > 0) { + return `QuickStack returned HTTP ${status}: ${parts.join(' - ')}`; } } + + if (typeof result === 'string' && result.trim()) { + return `QuickStack returned HTTP ${status}: ${result.trim()}`; + } + return `QuickStack returned HTTP ${status}.`; } + + private formatValidationEntry(entry: unknown): string | undefined { + if (typeof entry === 'string') { + return entry; + } + if (!entry || typeof entry !== 'object') { + return undefined; + } + + const record = entry as Record; + const message = typeof record.message === 'string' + ? record.message + : typeof record.detail === 'string' + ? record.detail + : undefined; + const path = record.path; + + if (Array.isArray(path)) { + return `${path.join('.') || '(root)'}: ${message ?? 'invalid value'}`; + } + if (typeof path === 'string') { + return `${path}: ${message ?? 'invalid value'}`; + } + return message; + } } const mcpServerFactory = new McpServerFactory(); diff --git a/src/server/utils/api-response.utils.ts b/src/server/utils/api-response.utils.ts index acf1c419..6214ef9e 100644 --- a/src/server/utils/api-response.utils.ts +++ b/src/server/utils/api-response.utils.ts @@ -10,8 +10,17 @@ const problemResponseSchema = z.object({ title: z.string(), status: z.number(), detail: z.string().optional(), + errors: z.array(z.object({ + path: z.string(), + message: z.string(), + })).optional(), }); +type ProblemFieldError = { + path: string; + message: string; +}; + export class ApiUtils { private static mapDateSchemaToStringDate(schema: any): z.ZodTypeAny { @@ -116,12 +125,13 @@ export class ApiUtils { return { identity }; }; - static problem(status: number, title: string, detail?: string): Response { + static problem(status: number, title: string, detail?: string, errors?: ProblemFieldError[]): Response { return new Response(JSON.stringify({ type: 'about:blank', title, status, - ...(detail ? { detail } : {}) + ...(detail ? { detail } : {}), + ...(errors && errors.length > 0 ? { errors } : {}), }), { status, headers: { 'content-type': 'application/problem+json' } @@ -130,10 +140,18 @@ export class ApiUtils { static mapError(error: unknown): Response { if (error instanceof ValidationError) { - return ApiUtils.problem(422, 'Unprocessable Entity', 'Request validation failed.'); + const errors = this.toFieldErrors(error); + const detail = errors.length > 0 + ? `Request validation failed: ${errors.map(entry => `${entry.path || '(root)'} - ${entry.message}`).join('; ')}` + : 'Request validation failed.'; + return ApiUtils.problem(422, 'Unprocessable Entity', detail, errors); } if (error instanceof z.ZodError) { - return ApiUtils.problem(400, 'Bad Request', 'Request validation failed. ' + error.issues.map(e => `${e.path.join('.')} - ${e.message}`).join('; ')); + const errors = error.issues.map(issue => ({ + path: issue.path.join('.'), + message: issue.message, + })); + return ApiUtils.problem(400, 'Bad Request', 'Request validation failed. ' + errors.map(entry => `${entry.path} - ${entry.message}`).join('; '), errors); } if (error instanceof ServiceException) { return ApiUtils.problem(400, 'Bad Request', error.message); @@ -145,6 +163,24 @@ export class ApiUtils { return ApiUtils.problem(500, 'Internal Server Error', 'An unknown error occurred.'); } + private static toFieldErrors(error: ValidationError): ProblemFieldError[] { + try { + return error.all.map((issue) => ({ + path: this.normalizeFieldPath(issue.path), + message: issue.message || 'Invalid value.', + })); + } catch { + return []; + } + } + + private static normalizeFieldPath(path: string | undefined): string { + if (!path) { + return ''; + } + return path.replace(/^\//, '').replace(/\//g, '.'); + } + static mapResponseModel(schema: z.ZodType) { return { 200: ApiUtils.mapDateSchemaToStringDate(schema) as z.ZodType, diff --git a/src/server/utils/api-response.utils.unit.spec.ts b/src/server/utils/api-response.utils.unit.spec.ts index 01c4aa84..ac387a9e 100644 --- a/src/server/utils/api-response.utils.unit.spec.ts +++ b/src/server/utils/api-response.utils.unit.spec.ts @@ -1,3 +1,4 @@ +import { Elysia } from "elysia"; import { z } from "zod"; import { ApiUtils } from "./api-response.utils"; @@ -42,4 +43,46 @@ describe("api-response.utils", () => { expect(responseSchema[200].parse({})).toEqual({}); }); }); + + describe("mapError", () => { + it("exposes Elysia request validation issues as field errors", async () => { + const app = new Elysia() + .onError(({ error }) => ApiUtils.mapError(error)) + .post("/apps", ({ body }) => body, { + body: z.object({ + name: z.string().min(3), + domains: z.array(z.object({ hostname: z.string().min(3) })), + }), + }); + + const response = await app.handle(new Request("http://localhost/apps", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ name: "x", domains: [{ hostname: "y" }] }), + })); + const problem = await response.json() as { + status: number; + detail: string; + errors: Array<{ path: string; message: string }>; + }; + + expect(response.status).toBe(422); + expect(problem.errors).toHaveLength(2); + expect(problem.errors.map((entry) => entry.path)).toEqual(["name", "domains.0.hostname"]); + expect(problem.detail).toContain("name - "); + expect(problem.detail).toContain("domains.0.hostname - "); + }); + + it("exposes Zod issues as field errors", async () => { + const problem = await ApiUtils.mapError(new z.ZodError([ + { code: "too_small", minimum: 3, origin: "string", path: ["name"], message: "Too small" }, + ])).json() as { + status: number; + errors: Array<{ path: string; message: string }>; + }; + + expect(problem.status).toBe(400); + expect(problem.errors).toEqual([{ path: "name", message: "Too small" }]); + }); + }); }); diff --git a/src/shared/utils/pagination.utils.ts b/src/shared/utils/pagination.utils.ts new file mode 100644 index 00000000..15c75822 --- /dev/null +++ b/src/shared/utils/pagination.utils.ts @@ -0,0 +1,76 @@ +import { z } from 'zod'; +import { ServiceException } from '@/shared/model/service.exception.model'; + +export const DEFAULT_PAGE_SIZE = 50; +export const MAX_PAGE_SIZE = 100; + +export const paginationQueryZodModel = z.object({ + limit: z.coerce.number().int().min(1).max(MAX_PAGE_SIZE).optional() + .describe(`Maximum number of items to return (1-${MAX_PAGE_SIZE}).`), + cursor: z.string().optional() + .describe('Opaque continuation cursor from a previous response. Send it together with limit.'), +}); + +export type PaginationQuery = z.infer; + +export type PaginationMeta = { + total: number; + limit: number; + nextCursor: string | null; +}; + +export type PaginatedResult = { + items: T[]; + total: number; + limit: number; + nextCursor: string | null; +}; + +class PaginationUtils { + + /** True when the caller explicitly opted into the paginated response envelope. */ + isRequested(query: PaginationQuery): boolean { + return query.limit !== undefined || query.cursor !== undefined; + } + + page(items: T[], query: PaginationQuery): PaginatedResult { + const limit = query.limit ?? DEFAULT_PAGE_SIZE; + const offset = this.parseCursor(query.cursor); + const pageItems = items.slice(offset, offset + limit); + const nextOffset = offset + pageItems.length; + + return { + items: pageItems, + total: items.length, + limit, + nextCursor: nextOffset < items.length ? String(nextOffset) : null, + }; + } + + envelopeSchema(itemSchema: T) { + return z.object({ + items: z.array(itemSchema), + total: z.number().int(), + limit: z.number().int(), + nextCursor: z.string().nullable(), + }); + } + + /** Bare array for existing callers, paginated envelope when limit/cursor is supplied. */ + responseSchema(itemSchema: T) { + return z.union([z.array(itemSchema), this.envelopeSchema(itemSchema)]); + } + + private parseCursor(cursor?: string): number { + if (cursor === undefined || cursor === '') { + return 0; + } + if (!/^\d+$/.test(cursor)) { + throw new ServiceException('Invalid cursor. Pass the nextCursor value from the previous response.'); + } + return Number(cursor); + } +} + +const paginationUtils = new PaginationUtils(); +export default paginationUtils; diff --git a/src/shared/utils/pagination.utils.unit.spec.ts b/src/shared/utils/pagination.utils.unit.spec.ts new file mode 100644 index 00000000..08c35bf1 --- /dev/null +++ b/src/shared/utils/pagination.utils.unit.spec.ts @@ -0,0 +1,77 @@ +import { z } from 'zod'; +import { ServiceException } from '@/shared/model/service.exception.model'; +import paginationUtils, { + DEFAULT_PAGE_SIZE, + MAX_PAGE_SIZE, + paginationQueryZodModel, +} from './pagination.utils'; + +describe('pagination.utils', () => { + describe('isRequested', () => { + it('is false for an empty query', () => { + expect(paginationUtils.isRequested({})).toBe(false); + }); + + it('is true when limit or cursor is supplied', () => { + expect(paginationUtils.isRequested({ limit: 10 })).toBe(true); + expect(paginationUtils.isRequested({ cursor: '20' })).toBe(true); + }); + }); + + describe('page', () => { + const items = Array.from({ length: 60 }, (_value, index) => index + 1); + + it('defaults to the default page size and exposes the next cursor', () => { + const result = paginationUtils.page(items, {}); + + expect(result.items).toHaveLength(DEFAULT_PAGE_SIZE); + expect(result.items[0]).toBe(1); + expect(result.items[DEFAULT_PAGE_SIZE - 1]).toBe(DEFAULT_PAGE_SIZE); + expect(result.total).toBe(60); + expect(result.limit).toBe(DEFAULT_PAGE_SIZE); + expect(result.nextCursor).toBe(String(DEFAULT_PAGE_SIZE)); + }); + + it('continues from an opaque cursor', () => { + const result = paginationUtils.page(items, { limit: 2, cursor: '2' }); + + expect(result.items).toEqual([3, 4]); + expect(result.nextCursor).toBe('4'); + }); + + it('returns a null cursor on the last page', () => { + const result = paginationUtils.page(items, { limit: 10, cursor: '50' }); + + expect(result.items).toEqual([51, 52, 53, 54, 55, 56, 57, 58, 59, 60]); + expect(result.nextCursor).toBeNull(); + }); + + it('rejects a malformed cursor', () => { + expect(() => paginationUtils.page(items, { cursor: 'not-a-cursor' })).toThrow(ServiceException); + expect(() => paginationUtils.page(items, { cursor: 'not-a-cursor' })).toThrow('Invalid cursor'); + }); + }); + + describe('query schema', () => { + it('coerces a string limit and keeps the cursor', () => { + expect(paginationQueryZodModel.parse({ limit: '25', cursor: '50' })).toEqual({ limit: 25, cursor: '50' }); + }); + + it('enforces the maximum page size', () => { + expect(paginationQueryZodModel.safeParse({ limit: MAX_PAGE_SIZE + 1 }).success).toBe(false); + }); + }); + + describe('responseSchema', () => { + const schema = paginationUtils.responseSchema(z.object({ id: z.string() })); + + it('accepts the legacy bare array response', () => { + expect(schema.parse([{ id: 'a' }])).toEqual([{ id: 'a' }]); + }); + + it('accepts the paginated envelope response', () => { + const envelope = { items: [{ id: 'a' }], total: 1, limit: 50, nextCursor: null }; + expect(schema.parse(envelope)).toEqual(envelope); + }); + }); +}); From 084dc4d59dc02f69fe41d82a3f9c64cce499daca Mon Sep 17 00:00:00 2001 From: aperolschpritz Date: Sun, 25 Oct 2026 19:58:34 +0000 Subject: [PATCH 2/7] feat: add MCP-specific operation descriptions --- .../v1/subitem-identity.integration.spec.ts | 12 +++- src/server/api/v1/agent/deploy/route.ts | 8 ++- src/server/api/v1/agent/route.ts | 15 +++-- src/server/api/v1/agent/sandbox/route.ts | 50 +++++++++------ src/server/api/v1/app/deploy/route.ts | 22 ++++--- src/server/api/v1/app/route.ts | 29 +++++---- src/server/api/v1/project/route.ts | 15 +++-- src/server/mcp/operation-registry.ts | 4 +- .../mcp/operation-registry.unit.spec.ts | 64 +++++++++++++++++++ .../utils/api-operation-detail.utils.ts | 30 +++++++++ 10 files changed, 188 insertions(+), 61 deletions(-) create mode 100644 src/server/mcp/operation-registry.unit.spec.ts create mode 100644 src/server/utils/api-operation-detail.utils.ts diff --git a/src/__tests__/integration/server/api/v1/subitem-identity.integration.spec.ts b/src/__tests__/integration/server/api/v1/subitem-identity.integration.spec.ts index 61998236..8e36545b 100644 --- a/src/__tests__/integration/server/api/v1/subitem-identity.integration.spec.ts +++ b/src/__tests__/integration/server/api/v1/subitem-identity.integration.spec.ts @@ -186,7 +186,10 @@ describe('REST API v1 integration - nested subitem identity', () => { body: payload, }), 422); - expect(problem.detail).toBe('Request validation failed.'); + expect(problem.detail).toContain('appNetworkPolicy'); + expect(problem.errors).toEqual(expect.arrayContaining([ + expect.objectContaining({ path: 'appNetworkPolicy' }), + ])); }); it('removes an App network policy configuration when the API receives null', async () => { @@ -373,7 +376,10 @@ describe('REST API v1 integration - nested subitem identity', () => { body: payload, }), 422); - expect(problem.detail).toBe('Request validation failed.'); + expect(problem.detail).toContain('agentNetworkPolicy'); + expect(problem.errors).toEqual(expect.arrayContaining([ + expect.objectContaining({ path: 'agentNetworkPolicy' }), + ])); }); it('removes an Agent network policy configuration when the API receives null', async () => { @@ -532,5 +538,5 @@ async function expectApiProblem(response: Response, status: number) { const json = text ? JSON.parse(text) : undefined; expect(response.status, JSON.stringify(json)).toBe(status); - return json as { detail?: string }; + return json as { detail?: string; errors?: Array<{ path: string; message: string }> }; } diff --git a/src/server/api/v1/agent/deploy/route.ts b/src/server/api/v1/agent/deploy/route.ts index 82cbd315..ef67ec3a 100644 --- a/src/server/api/v1/agent/deploy/route.ts +++ b/src/server/api/v1/agent/deploy/route.ts @@ -6,6 +6,7 @@ import { } from '@/server/utils/shared-authorization.utils'; import { ApiNotFoundException, ApiUnauthorizedException } from '@/shared/model/service.exception.model'; import { ApiUtils } from '@/server/utils/api-response.utils'; +import { ApiOperationDetailUtils } from '@/server/utils/api-operation-detail.utils'; export const agentDeployRoutes = new Elysia() .derive(ApiUtils.deriveFunc) @@ -27,11 +28,12 @@ export const agentDeployRoutes = new Elysia() forceRebuild: z.coerce.boolean().optional().default(false), }), response: ApiUtils.mapResponseModel(z.object({ deploymentId: z.string() })), - detail: { + detail: ApiOperationDetailUtils.create({ summary: 'Deploy agent configuration', - description: 'Deploys the agent configuration with the given ID. This does not start any agent sandbox; it only applies the configuration to Kubernetes (SandboxTemplate). Every new agent sandbox created after this deployment uses the newly applied configuration. This can only be done when no sandbox is running. For CONTAINER sources the SandboxTemplate is applied before the call returns. For GIT/GIT_SSH sources the build continues asynchronously after the returned deploymentId; follow its progress in the QuickStack UI and poll listAgentSandboxes before starting a sandbox.\nExample: POST /api/v1/agents//deploy?forceRebuild=false.\nThis changes live Kubernetes resources; ask for confirmation first.', + description: 'Deploys the agent configuration to Kubernetes as a SandboxTemplate. Does not start a sandbox. Can only be done when no sandbox is running.', + 'x-mcp-description': 'Deploys the agent configuration with the given ID. This does not start any agent sandbox; it only applies the configuration to Kubernetes (SandboxTemplate). Every new agent sandbox created after this deployment uses the newly applied configuration. This can only be done when no sandbox is running. For CONTAINER sources the SandboxTemplate is applied before the call returns. For GIT/GIT_SSH sources the build continues asynchronously after the returned deploymentId; follow its progress in the QuickStack UI and poll listAgentSandboxes before starting a sandbox.\nExample: POST /api/v1/agents//deploy?forceRebuild=false.\nThis changes live Kubernetes resources; ask for confirmation first.', operationId: 'deployAgent', tags: ['Agents'], security: [{ bearerAuth: [] }] - } + }) }); diff --git a/src/server/api/v1/agent/route.ts b/src/server/api/v1/agent/route.ts index 18a1c369..2642a5c2 100644 --- a/src/server/api/v1/agent/route.ts +++ b/src/server/api/v1/agent/route.ts @@ -10,6 +10,7 @@ import { import { UserGroupUtils } from '@/shared/utils/role.utils'; import { AgentExtendedModel, AgentExtendedWriteModel, AgentExtendedWriteZodModel, AgentExtendedZodModel } from '@/shared/model/agent-extended.model'; import { ApiUtils } from '../../../utils/api-response.utils'; +import { ApiOperationDetailUtils } from '@/server/utils/api-operation-detail.utils'; import { ApiNotFoundException, ApiUnauthorizedException, ServiceException } from '@/shared/model/service.exception.model'; import paginationUtils, { paginationQueryZodModel } from '@/shared/utils/pagination.utils'; @@ -100,9 +101,10 @@ export const agentRoutes = new Elysia() }, { body: AgentExtendedWriteZodModel, response: ApiUtils.mapResponseModel(AgentExtendedZodModel), - detail: { + detail: ApiOperationDetailUtils.create({ summary: 'Create or update agent.', - description: `Upsert one agent template. + description: 'Creates an agent template when id is omitted, otherwise replaces the existing template. projectId cannot be changed. Call deployAgent afterwards to apply it.', + 'x-mcp-description': `Upsert one agent template. - Omit id to create: projectId, llmGatewayId and modelAlias (non-empty array) are required. agentDomains, agentVolumes, agentFileMounts are required arrays, and agentNetworkPolicy must be null or an object. - Send id to update: projectId must equal the existing agent's projectId and cannot be changed. The request replaces the stored agent, so omitted or empty sub-resource arrays delete the existing entries. Call getAgent first, then send the full object back with the ids of sub-resources you want to keep. - This only saves the template. Call deployAgent to apply it, then startAgentSandbox to run a sandbox. Saving revalidates caches but does not create Kubernetes resources. @@ -115,7 +117,7 @@ Example update (read getAgent first and keep all sub-resource ids you do not wan operationId: 'saveAgent', tags: ['Agents'], security: [{ bearerAuth: [] }] - } + }) }) .delete('/agents/:agentId', async ({ params, identity }) => { if (!identity) throw new ApiUnauthorizedException() @@ -132,11 +134,12 @@ Example update (read getAgent first and keep all sub-resource ids you do not wan agentId: z.string(), }), response: ApiUtils.mapResponseModel(z.undefined()), - detail: { + detail: ApiOperationDetailUtils.create({ summary: 'Delete agent', - description: 'Permanently deletes the agent template and its sub-resources (domains, volumes, file mounts, network policy). No request body.\nExample: DELETE /api/v1/agents/.\nRequires confirmation because this cannot be undone.', + description: 'Permanently deletes the agent template and its sub-resources (domains, volumes, file mounts, network policy).', + 'x-mcp-description': 'Permanently deletes the agent template and its sub-resources (domains, volumes, file mounts, network policy). No request body.\nExample: DELETE /api/v1/agents/.\nRequires confirmation because this cannot be undone.', operationId: 'deleteAgent', tags: ['Agents'], security: [{ bearerAuth: [] }], - } + }) }); diff --git a/src/server/api/v1/agent/sandbox/route.ts b/src/server/api/v1/agent/sandbox/route.ts index 9910d53d..b4b0dff6 100644 --- a/src/server/api/v1/agent/sandbox/route.ts +++ b/src/server/api/v1/agent/sandbox/route.ts @@ -23,6 +23,7 @@ import { } from '@/shared/model/agent-sandbox.model'; import { ApiNotFoundException, ApiUnauthorizedException } from '@/shared/model/service.exception.model'; import { ApiUtils } from '@/server/utils/api-response.utils'; +import { ApiOperationDetailUtils } from '@/server/utils/api-operation-detail.utils'; import agentAccessService from '@/server/services/agent-access.service'; import { FileUploadUtils } from '@/server/utils/file-upload.utils'; import paginationUtils, { paginationQueryZodModel } from '@/shared/utils/pagination.utils'; @@ -105,15 +106,16 @@ export const agentSandboxRoutes = new Elysia() }), body: startSandboxRequestZodModel, response: ApiUtils.mapResponseModel(agentSandboxZodModel), - detail: { + detail: ApiOperationDetailUtils.create({ summary: 'Start agent sandbox', - description: `Creates and starts one agent sandbox for this agent. Body is optional and defaults to {}. + description: 'Creates and starts one agent sandbox for this agent. The body is optional and defaults to {}. Changes live Kubernetes resources.', + 'x-mcp-description': `Creates and starts one agent sandbox for this agent. Body is optional and defaults to {}. Example: POST /api/v1/agents//sandboxes?timeoutMs=300000 with body {"customTag":"review","idleTimeoutMinutes":30,"env":{"FOO":"bar"}} customTag must be 1-63 characters; idleTimeoutMinutes is 1-1440; env names must match [A-Za-z_][A-Za-z0-9_]*. Query timeoutMs (max 900000, default 300000) controls how long the API waits for the sandbox to start. This creates live Kubernetes resources; ask for confirmation first.`, operationId: 'startAgentSandbox', tags: ['Agent Sandboxes'], security: [{ bearerAuth: [] }], - }, + }) }) .post('/agents/:agentId/sandboxes/resume', async ({ params, body, identity }) => { if (!identity) throw new ApiUnauthorizedException(); @@ -126,13 +128,14 @@ customTag must be 1-63 characters; idleTimeoutMinutes is 1-1440; env names must params: agentSandboxParamsSchema, body: resumeSandboxByTagRequestZodModel, response: ApiUtils.mapResponseModel(agentSandboxZodModel), - detail: { + detail: ApiOperationDetailUtils.create({ summary: 'Resume agent sandbox by Custom Tag', - description: 'Resume the suspended Agent Sandbox of this Agent that carries the given Custom Tag. Recreates the Pod with the existing volumes mounted.\nExample: POST /api/v1/agents//sandboxes/resume with body {"customTag":"review"}.', + description: 'Resumes the suspended Agent Sandbox of this Agent that carries the given Custom Tag.', + 'x-mcp-description': 'Resume the suspended Agent Sandbox of this Agent that carries the given Custom Tag. Recreates the Pod with the existing volumes mounted.\nExample: POST /api/v1/agents//sandboxes/resume with body {"customTag":"review"}.', operationId: 'resumeAgentSandboxByTag', tags: ['Agent Sandboxes'], security: [{ bearerAuth: [] }], - }, + }) }) .post('/agents/:agentId/sandboxes/:sandboxName/suspend', async ({ params, identity }) => { if (!identity) throw new ApiUnauthorizedException(); @@ -144,13 +147,14 @@ customTag must be 1-63 characters; idleTimeoutMinutes is 1-1440; env names must }, { params: agentSandboxClaimParamsSchema, response: ApiUtils.mapResponseModel(agentSandboxZodModel), - detail: { + detail: ApiOperationDetailUtils.create({ summary: 'Suspend agent sandbox', - description: 'Release the compute of a running Agent Sandbox while keeping its volumes and identity. No request body. The sandbox Pod is deleted and can be resumed later.\nExample: POST /api/v1/agents//sandboxes//suspend.', + description: 'Releases the compute of a running Agent Sandbox while keeping its volumes and identity. The sandbox Pod is deleted and can be resumed later.', + 'x-mcp-description': 'Release the compute of a running Agent Sandbox while keeping its volumes and identity. No request body. The sandbox Pod is deleted and can be resumed later.\nExample: POST /api/v1/agents//sandboxes//suspend.', operationId: 'suspendAgentSandbox', tags: ['Agent Sandboxes'], security: [{ bearerAuth: [] }], - }, + }) }) .post('/agents/:agentId/sandboxes/:sandboxName/resume', async ({ params, identity }) => { if (!identity) throw new ApiUnauthorizedException(); @@ -162,13 +166,14 @@ customTag must be 1-63 characters; idleTimeoutMinutes is 1-1440; env names must }, { params: agentSandboxClaimParamsSchema, response: ApiUtils.mapResponseModel(agentSandboxZodModel), - detail: { + detail: ApiOperationDetailUtils.create({ summary: 'Resume agent sandbox', - description: 'Recreate the Pod of a suspended Agent Sandbox with its existing volumes mounted. No request body.\nExample: POST /api/v1/agents//sandboxes//resume.', + description: 'Recreates the Pod of a suspended Agent Sandbox with its existing volumes mounted.', + 'x-mcp-description': 'Recreate the Pod of a suspended Agent Sandbox with its existing volumes mounted. No request body.\nExample: POST /api/v1/agents//sandboxes//resume.', operationId: 'resumeAgentSandbox', tags: ['Agent Sandboxes'], security: [{ bearerAuth: [] }], - }, + }) }) .get('/agents/:agentId/sandboxes/:sandboxName', async ({ params, identity }) => { if (!identity) throw new ApiUnauthorizedException(); @@ -250,13 +255,14 @@ customTag must be 1-63 characters; idleTimeoutMinutes is 1-1440; env names must }, { params: agentSandboxClaimParamsSchema, response: ApiUtils.mapResponseModel(z.undefined()), - detail: { + detail: ApiOperationDetailUtils.create({ summary: 'Delete agent sandbox', - description: 'Permanently deletes the agent sandbox and stops its Pod. No request body.\nExample: DELETE /api/v1/agents//sandboxes/.\nRequires confirmation because the running sandbox cannot be recovered.', + description: 'Permanently deletes the agent sandbox and stops its Pod.', + 'x-mcp-description': 'Permanently deletes the agent sandbox and stops its Pod. No request body.\nExample: DELETE /api/v1/agents//sandboxes/.\nRequires confirmation because the running sandbox cannot be recovered.', operationId: 'deleteAgentSandbox', tags: ['Agent Sandboxes'], security: [{ bearerAuth: [] }], - }, + }) }) .delete('/agents/:agentId/tags/:customTag', async ({ params, identity }) => { if (!identity) throw new ApiUnauthorizedException(); @@ -269,13 +275,14 @@ customTag must be 1-63 characters; idleTimeoutMinutes is 1-1440; env names must }, { params: agentTagParamsSchema, response: ApiUtils.mapResponseModel(z.undefined()), - detail: { + detail: ApiOperationDetailUtils.create({ summary: 'Delete Custom Tag data', - description: 'Delete all stored data of one Custom Tag across the Agent\'s PER_CUSTOM_TAG volumes. Rejected while a sandbox with that tag exists. No request body.\nExample: DELETE /api/v1/agents//tags/.\nRequires confirmation because this cannot be undone.', + description: 'Deletes all stored data of one Custom Tag across the Agent\'s PER_CUSTOM_TAG volumes. Rejected while a sandbox with that tag exists.', + 'x-mcp-description': 'Delete all stored data of one Custom Tag across the Agent\'s PER_CUSTOM_TAG volumes. Rejected while a sandbox with that tag exists. No request body.\nExample: DELETE /api/v1/agents//tags/.\nRequires confirmation because this cannot be undone.', operationId: 'deleteAgentTag', tags: ['Agent Sandboxes'], security: [{ bearerAuth: [] }], - }, + }) }) .post('/agents/:agentId/sandboxes/:sandboxName/commands', async ({ params, body, identity }) => { if (!identity) throw new ApiUnauthorizedException(); @@ -288,15 +295,16 @@ customTag must be 1-63 characters; idleTimeoutMinutes is 1-1440; env names must params: agentSandboxClaimParamsSchema, body: commandRequestZodModel, response: ApiUtils.mapResponseModel(commandResultZodModel), - detail: { + detail: ApiOperationDetailUtils.create({ summary: 'Run command in agent sandbox', - description: `Run a shell command in a running Agent Sandbox. CLI harness templates stay alive with \`sleep infinity\`; invoke their documented launcher or bootstrap script here so it receives the Agent Runtime Secret and selected LiteLLM Model Alias. + description: 'Runs a shell command in a running Agent Sandbox. timeoutSec is 1-3600 (default 120). Returns stdout, stderr and exitCode.', + 'x-mcp-description': `Run a shell command in a running Agent Sandbox. CLI harness templates stay alive with \`sleep infinity\`; invoke their documented launcher or bootstrap script here so it receives the Agent Runtime Secret and selected LiteLLM Model Alias. Example: {"command":"pwd && ls -la","cwd":"/workspace","timeoutSec":120,"env":{"FOO":"bar"}} timeoutSec is 1-3600 (default 120). Returns stdout, stderr and exitCode. Requires confirmation because the command can modify sandbox state.`, operationId: 'runAgentSandboxCommand', tags: ['Agent Sandboxes'], security: [{ bearerAuth: [] }], - }, + }) }) .get('/agents/:agentId/sandboxes/:sandboxName/files/read', async ({ params, query, identity }) => { if (!identity) throw new ApiUnauthorizedException(); diff --git a/src/server/api/v1/app/deploy/route.ts b/src/server/api/v1/app/deploy/route.ts index 96c5890a..8dffb8eb 100644 --- a/src/server/api/v1/app/deploy/route.ts +++ b/src/server/api/v1/app/deploy/route.ts @@ -11,6 +11,7 @@ import { ApiNotFoundException, ApiUnauthorizedException } from '@/shared/model/s import { appDeploymentLogsResponseZodModel } from '@/shared/model/app-tail-log-entry'; import { deploymentDetailsResponseZodModel } from '@/shared/model/deployment-info.model'; import { ApiUtils } from '@/server/utils/api-response.utils'; +import { ApiOperationDetailUtils } from '@/server/utils/api-operation-detail.utils'; import paginationUtils, { paginationQueryZodModel } from '@/shared/utils/pagination.utils'; export const appDeployRoutes = new Elysia() @@ -38,13 +39,14 @@ export const appDeployRoutes = new Elysia() }), query: paginationQueryZodModel, response: ApiUtils.mapResponseModel(paginationUtils.responseSchema(deploymentDetailsResponseZodModel)), - detail: { + detail: ApiOperationDetailUtils.create({ summary: 'List app deployments', - description: 'Returns the deployment history as an array. Set limit (and optionally cursor) for the paginated envelope. Poll this after deployApp until the newest entry reports success or failure.', + description: 'Returns the deployment history as an array. Set limit (and optionally cursor) for the paginated envelope.', + 'x-mcp-description': 'Returns the deployment history as an array. Set limit (and optionally cursor) for the paginated envelope. Poll this after deployApp until the newest entry reports success or failure.', operationId: 'listAppDeployments', tags: ['Apps'], security: [{ bearerAuth: [] }], - } + }) }) .get('/apps/:appId/deploy/:deployemtId', async ({ params, identity }) => { if (!identity) throw new ApiUnauthorizedException() @@ -96,13 +98,14 @@ export const appDeployRoutes = new Elysia() tailLines: z.coerce.number().int().positive().max(5000).optional(), }), response: ApiUtils.mapResponseModel(appDeploymentLogsResponseZodModel), - detail: { + detail: ApiOperationDetailUtils.create({ summary: 'Get app deployment logs', - description: 'Returns build/deploy logs for one deployment. Keep tailLines small (max 5000); large responses are truncated by MCP clients.', + description: 'Returns build/deploy logs for one deployment. tailLines is limited to 5000.', + 'x-mcp-description': 'Returns build/deploy logs for one deployment. Keep tailLines small (max 5000); large responses are truncated by MCP clients.', operationId: 'getAppDeploymentLogs', tags: ['Apps'], security: [{ bearerAuth: [] }], - } + }) }) .post('/apps/:appId/deploy', async ({ params, identity }) => { if (!identity) throw new ApiUnauthorizedException() @@ -120,13 +123,14 @@ export const appDeployRoutes = new Elysia() forceRebuild: z.coerce.boolean().optional().default(false), }), response: ApiUtils.mapResponseModel(z.object({ deploymentId: z.string() })), - detail: { + detail: ApiOperationDetailUtils.create({ summary: 'Deploy app', - description: `Starts a build and rollout and returns {"deploymentId":"..."} immediately. Pass query.forceRebuild=true to rebuild even when the commit/image did not change. + description: 'Starts a build and rollout and returns the deploymentId immediately. Pass forceRebuild=true to rebuild even when the commit/image did not change. This changes live Kubernetes workloads.', + 'x-mcp-description': `Starts a build and rollout and returns {"deploymentId":"..."} immediately. Pass query.forceRebuild=true to rebuild even when the commit/image did not change. Example: POST /api/v1/apps//deploy?forceRebuild=true. Poll listAppDeployments or getAppDeployment with that deploymentId until the status is successful or failed. On failure, call getAppDeploymentLogs to inspect the build logs. This changes live Kubernetes workloads; ask for confirmation first.`, operationId: 'deployApp', tags: ['Apps'], security: [{ bearerAuth: [] }] - } + }) }); diff --git a/src/server/api/v1/app/route.ts b/src/server/api/v1/app/route.ts index 8f04a541..fac40592 100644 --- a/src/server/api/v1/app/route.ts +++ b/src/server/api/v1/app/route.ts @@ -13,6 +13,7 @@ import { import { UserGroupUtils } from '@/shared/utils/role.utils'; import { AppExtendedModel, AppExtendedWriteModel, AppExtendedWriteZodModel, AppExtendedZodModel } from '@/shared/model/app-extended.model'; import { ApiUtils } from '../../../utils/api-response.utils'; +import { ApiOperationDetailUtils } from '@/server/utils/api-operation-detail.utils'; import { ApiNotFoundException, ApiUnauthorizedException, ServiceException } from '@/shared/model/service.exception.model'; import { appLogsResponseZodModel } from '@/shared/model/app-tail-log-entry'; import { commandRequestZodModel, commandResultZodModel } from '@/shared/model/agent-sandbox.model'; @@ -103,13 +104,14 @@ export const appRoutes = new Elysia() lines: z.coerce.number().int().positive().max(5000).optional().default(200) }), response: ApiUtils.mapResponseModel(appLogsResponseZodModel), - detail: { + detail: ApiOperationDetailUtils.create({ summary: 'Get current app logs', - description: 'Returns the current log tail of the app. Keep `lines` small (default 200, max 5000); large responses are truncated by MCP clients.', + description: 'Returns the current log tail of the app. `lines` defaults to 200 and is limited to 5000.', + 'x-mcp-description': 'Returns the current log tail of the app. Keep `lines` small (default 200, max 5000); large responses are truncated by MCP clients.', operationId: 'getAppLogs', tags: ['Apps'], security: [{ bearerAuth: [] }], - } + }) }) .post('/apps/:appId/commands', async ({ params, body, identity }) => { if (!identity) throw new ApiUnauthorizedException(); @@ -126,15 +128,16 @@ export const appRoutes = new Elysia() }), body: commandRequestZodModel, response: ApiUtils.mapResponseModel(commandResultZodModel), - detail: { + detail: ApiOperationDetailUtils.create({ summary: 'Run command in app', - description: `Run a shell command in the first running Pod of this app. Requires write access to the app. + description: 'Runs a shell command in the first running Pod of the app. Returns stdout, stderr and exitCode.', + 'x-mcp-description': `Run a shell command in the first running Pod of this app. Requires write access to the app. Example: {"command":"ls -la /app","cwd":"/app","timeoutSec":120} Returns stdout, stderr and exitCode. The command is executed inside the live Pod and can modify application data.`, operationId: 'runAppCommand', tags: ['Apps'], security: [{ bearerAuth: [] }], - }, + }), }) .post('/apps', async ({ body, identity }) => { if (!identity) throw new ApiUnauthorizedException() @@ -161,9 +164,10 @@ Returns stdout, stderr and exitCode. The command is executed inside the live Pod }, { body: AppExtendedWriteZodModel, response: ApiUtils.mapResponseModel(AppExtendedZodModel), - detail: { + detail: ApiOperationDetailUtils.create({ summary: 'Create or update app.', - description: `Upsert one App. + description: 'Creates an app when id is omitted, otherwise replaces the existing app. projectId cannot be changed. Call deployApp afterwards to roll out the desired state.', + 'x-mcp-description': `Upsert one App. - Omit id to create: projectId is required. appDomains, appNodePorts, appFileMounts, appVolumes and appBasicAuths are required arrays (empty allowed), and appNetworkPolicy must be null or an object. - Send id to update: projectId must equal the existing app's projectId and cannot be changed. The request replaces the stored app, so omitted or empty sub-resource arrays delete the existing entries. Call getApp first, then send the full object back with the ids of sub-resources you want to keep. - This only saves desired state. Call deployApp afterwards to roll it out. Saving revalidates server caches but does not change Kubernetes resources. @@ -176,7 +180,7 @@ Example update (read getApp first and keep all sub-resource ids you do not want operationId: 'saveApp', tags: ['Apps'], security: [{ bearerAuth: [] }] - } + }) }) .delete('/apps/:id', async ({ params, identity }) => { if (!identity) throw new ApiUnauthorizedException() @@ -193,11 +197,12 @@ Example update (read getApp first and keep all sub-resource ids you do not want id: z.string(), }), response: ApiUtils.mapResponseModel(z.undefined()), - detail: { + detail: ApiOperationDetailUtils.create({ summary: 'Delete app', - description: 'Permanently deletes the app and its sub-resources (domains, volumes, file mounts, basic auth). No request body.\nExample: DELETE /api/v1/apps/.\nRequires confirmation because this cannot be undone.', + description: 'Permanently deletes the app and its sub-resources (domains, volumes, file mounts, basic auth).', + 'x-mcp-description': 'Permanently deletes the app and its sub-resources (domains, volumes, file mounts, basic auth). No request body.\nExample: DELETE /api/v1/apps/.\nRequires confirmation because this cannot be undone.', operationId: 'deleteApp', tags: ['Apps'], security: [{ bearerAuth: [] }], - } + }) }); diff --git a/src/server/api/v1/project/route.ts b/src/server/api/v1/project/route.ts index 65e15858..82659d02 100644 --- a/src/server/api/v1/project/route.ts +++ b/src/server/api/v1/project/route.ts @@ -5,6 +5,7 @@ import { ensureAdmin, ensureReadProject } from '@/server/utils/shared-authorizat import { UserGroupUtils } from '@/shared/utils/role.utils'; import { ProjectModel } from '@/shared/model/generated-zod'; import { ApiUtils } from '../../../utils/api-response.utils'; +import { ApiOperationDetailUtils } from '@/server/utils/api-operation-detail.utils'; import { Project } from '@prisma/client'; import { ApiNotFoundException, ApiUnauthorizedException } from '@/shared/model/service.exception.model'; import paginationUtils, { paginationQueryZodModel } from '@/shared/utils/pagination.utils'; @@ -76,16 +77,17 @@ export const projectRoutes = new Elysia() }, { body: projectWriteSchema, response: ApiUtils.mapResponseModel(ProjectModel), - detail: { + detail: ApiOperationDetailUtils.create({ summary: 'Create or update project', - description: `Upsert one project. Omit id to create; send id to update an existing project. + description: 'Creates a project when id is omitted, otherwise updates the existing project. Requires admin.', + 'x-mcp-description': `Upsert one project. Omit id to create; send id to update an existing project. Example create: {"name":"team-demo"} Example update: {"id":"","name":"team-demo-renamed"} Requires admin. Every project maps to a Kubernetes namespace; saveProject only stores metadata, deployApp or deployAgent creates the namespace.`, operationId: 'saveProject', tags: ['Projects'], security: [{ bearerAuth: [] }], - } + }) }) .delete('/projects/:id', async ({ params, identity }) => { if (!identity) throw new ApiUnauthorizedException() @@ -101,11 +103,12 @@ Requires admin. Every project maps to a Kubernetes namespace; saveProject only s id: z.string(), }), response: ApiUtils.mapResponseModel(z.undefined()), - detail: { + detail: ApiOperationDetailUtils.create({ summary: 'Delete project', - description: 'Permanently deletes the project, all its Apps and Agents, and the Kubernetes namespace with their workloads and volumes. No request body.\nExample: DELETE /api/v1/projects/.\nRequires admin and confirmation because this cannot be undone.', + description: 'Permanently deletes the project, all its Apps and Agents, and the Kubernetes namespace with their workloads and volumes. Requires admin.', + 'x-mcp-description': 'Permanently deletes the project, all its Apps and Agents, and the Kubernetes namespace with their workloads and volumes. No request body.\nExample: DELETE /api/v1/projects/.\nRequires admin and confirmation because this cannot be undone.', operationId: 'deleteProject', tags: ['Projects'], security: [{ bearerAuth: [] }], - } + }) }); diff --git a/src/server/mcp/operation-registry.ts b/src/server/mcp/operation-registry.ts index c8f8927f..3636167a 100644 --- a/src/server/mcp/operation-registry.ts +++ b/src/server/mcp/operation-registry.ts @@ -1,4 +1,5 @@ import { z } from 'zod'; +import { ApiOperationDetailUtils } from '../utils/api-operation-detail.utils'; export type McpSourceApp = { routes: unknown[]; @@ -38,6 +39,7 @@ type ElysiaRoute = { operationId?: string; summary?: string; description?: string; + 'x-mcp-description'?: string; tags?: string[]; }; }; @@ -84,7 +86,7 @@ class OperationRegistry { method: route.method, path: route.path, summary: detail?.summary, - description: detail?.description, + description: ApiOperationDetailUtils.mcpDescription(detail), tags: detail?.tags ?? [], pathParamsSchema: this.toJsonSchema(route.hooks?.params), querySchema: this.toJsonSchema(route.hooks?.query), diff --git a/src/server/mcp/operation-registry.unit.spec.ts b/src/server/mcp/operation-registry.unit.spec.ts new file mode 100644 index 00000000..d8a18ab3 --- /dev/null +++ b/src/server/mcp/operation-registry.unit.spec.ts @@ -0,0 +1,64 @@ +import { Elysia } from 'elysia'; +import { z } from 'zod'; +import { openapi } from '@elysiajs/openapi'; +import operationRegistry, { McpSourceApp } from './operation-registry'; +import { ApiOperationDetailUtils } from '../utils/api-operation-detail.utils'; + +const app = new Elysia() + .post('/apps', () => ({ name: 'web' }), { + body: z.object({ + name: z.string(), + }), + response: { + 200: z.object({ + name: z.string(), + }), + }, + detail: ApiOperationDetailUtils.create({ + summary: 'Create app', + description: 'OpenAPI description for saveApp.', + 'x-mcp-description': 'MCP description for saveApp.', + operationId: 'saveApp', + tags: ['Apps'], + }), + }) + .get('/apps', () => [], { + response: { + 200: z.array(z.string()), + }, + detail: { + summary: 'List apps', + description: 'Fallback description for listApps.', + operationId: 'listApps', + tags: ['Apps'], + }, + }); + +describe('operation registry descriptions', () => { + + it('prefers the MCP description and falls back to the OpenAPI description', () => { + const descriptors = operationRegistry.build(app as unknown as McpSourceApp); + + expect(descriptors.find((descriptor) => descriptor.operationId === 'saveApp')?.description) + .toBe('MCP description for saveApp.'); + expect(descriptors.find((descriptor) => descriptor.operationId === 'listApps')?.description) + .toBe('Fallback description for listApps.'); + }); + + it('keeps the OpenAPI description separate and exposes the MCP description as extension', async () => { + const documentedApp = new Elysia() + .use(openapi({ + path: '/openapi', + specPath: '/openapi.json', + })) + .use(app); + + const response = await documentedApp.handle(new Request('http://localhost/openapi.json')); + const document = await response.json() as { + paths: Record>>; + }; + + expect(document.paths['/apps'].post.description).toBe('OpenAPI description for saveApp.'); + expect(document.paths['/apps'].post['x-mcp-description']).toBe('MCP description for saveApp.'); + }); +}); diff --git a/src/server/utils/api-operation-detail.utils.ts b/src/server/utils/api-operation-detail.utils.ts new file mode 100644 index 00000000..1d167221 --- /dev/null +++ b/src/server/utils/api-operation-detail.utils.ts @@ -0,0 +1,30 @@ +import type { DocumentDecoration } from 'elysia'; + +/** + * Operation metadata that carries both the public OpenAPI description and the + * MCP-specific description used by the operation registry. + * + * `x-mcp-description` is a valid OpenAPI vendor extension, so the generated + * document stays valid while MCP receives the agent-oriented text. + */ +export type ApiOperationDetail = DocumentDecoration & { + 'x-mcp-description'?: string; +}; + +type DescriptionSource = { + description?: string; + 'x-mcp-description'?: string; +}; + +export class ApiOperationDetailUtils { + + /** Widens the Elysia detail type so routes can declare the MCP override. */ + static create(detail: ApiOperationDetail): DocumentDecoration { + return detail; + } + + /** MCP uses the override when present, otherwise the public description. */ + static mcpDescription(detail: DescriptionSource | undefined): string | undefined { + return detail?.['x-mcp-description'] ?? detail?.description; + } +} From 4ec0e1410239dcaa219eadf08ac03d166af12973 Mon Sep 17 00:00:00 2001 From: aperolschpritz Date: Sun, 25 Oct 2026 20:13:46 +0000 Subject: [PATCH 3/7] feat: add mcp access modes and secret redaction --- README.md | 2 +- docs/mcp/evaluation.md | 7 +- .../mcp/mcp-handler.integration.spec.ts | 30 +++ src/app/api/mcp/route.ts | 9 +- src/app/api/mcp/route.unit.spec.ts | 53 +++-- src/app/settings/actions.ts | 28 ++- .../settings/developer/mcp/mcp-settings.tsx | 108 +++++++--- src/app/settings/developer/mcp/page.tsx | 7 +- src/server/mcp/mcp-handler.ts | 39 +++- src/server/mcp/mcp-handler.unit.spec.ts | 147 ++++++++++++- src/server/mcp/mcp-secret-redaction.ts | 204 ++++++++++++++++++ .../mcp/mcp-secret-redaction.unit.spec.ts | 122 +++++++++++ src/server/mcp/mcp-server.factory.ts | 79 +++++-- src/server/services/param.service.ts | 22 ++ src/server/utils/api-response.utils.ts | 31 ++- .../utils/api-response.utils.unit.spec.ts | 13 ++ src/shared/model/agent-extended.model.ts | 12 +- src/shared/model/agent-sandbox.model.ts | 4 +- src/shared/model/app-extended.model.ts | 10 +- .../model/mcp-secret-metadata.unit.spec.ts | 91 ++++++++ src/shared/model/mcp-settings.model.ts | 11 + src/shared/utils/mcp.utils.ts | 12 ++ 22 files changed, 951 insertions(+), 90 deletions(-) create mode 100644 src/server/mcp/mcp-secret-redaction.ts create mode 100644 src/server/mcp/mcp-secret-redaction.unit.spec.ts create mode 100644 src/shared/model/mcp-secret-metadata.unit.spec.ts create mode 100644 src/shared/model/mcp-settings.model.ts create mode 100644 src/shared/utils/mcp.utils.ts diff --git a/README.md b/README.md index 970dd926..e7cf3ba1 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,7 @@ After installation, open QuickStack in your browser and start deploying your app - **Backups you control:** Schedule volume backups to S3-compatible storage, optionally run them before deployments, and create native database dumps. - **Team-ready access:** Control project and app access with user groups, roles, OIDC SSO, 2FA, and expiring API keys. - **Automation and rollbacks:** Deploy via webhook or REST API and roll back to a previous version in one click. -- **MCP integration (canary):** Enable the remote Model Context Protocol server for controlled QuickStack access from compatible AI clients. +- **MCP integration (canary):** Expose read-only or read-write Model Context Protocol access to compatible AI clients, with secret fields redacted by default. - **Self-hosted and open source:** Install with one command, update from the UI, and avoid lock-in with GPL-3.0 source code. QuickStack app overview diff --git a/docs/mcp/evaluation.md b/docs/mcp/evaluation.md index 5e514423..565b9dd3 100644 --- a/docs/mcp/evaluation.md +++ b/docs/mcp/evaluation.md @@ -11,8 +11,11 @@ surface honest: ## Prerequisites -- A running QuickStack instance with the MCP canary and the MCP server enabled - in Settings → Developer → MCP. +- A running QuickStack instance with the MCP canary active and the MCP server + set to **Read only** or **Read & write** in Settings → Developer → MCP. +- Secret fields are redacted from MCP responses unless **Send secrets in MCP + responses** is enabled. Scripted and LLM evals should normally run with + redaction enabled. - A REST API key from Settings → Account & Access → API Keys. - Node.js 24 (uses the built-in `fetch` and `parseArgs`). diff --git a/src/__tests__/integration/server/mcp/mcp-handler.integration.spec.ts b/src/__tests__/integration/server/mcp/mcp-handler.integration.spec.ts index 6bc245ab..e4fd12cb 100644 --- a/src/__tests__/integration/server/mcp/mcp-handler.integration.spec.ts +++ b/src/__tests__/integration/server/mcp/mcp-handler.integration.spec.ts @@ -98,6 +98,36 @@ describe('QuickStack MCP integration', () => { expect(byId.get('writeAgentSandboxFile')?.supported).toBe(false); }, 120_000); + it('only exposes read operations in read-only mode', async () => { + const apiKey = await createAdminApiKey(); + const handler = createQuickStackMcpHandler({ app: v1Api, resolveAuthInfo: resolveQuickStackAuthInfo }); + const options = { mode: 'readonly' as const, sendSecrets: false }; + + const toolsResponse = await handler.fetch(jsonRpcRequest({ + method: 'tools/list', + apiKey, + }), options); + const toolsBody = await toolsResponse.json(); + const toolNames = toolsBody.result.tools.map((tool: { name: string }) => tool.name).sort(); + + expect(toolNames).toEqual([ + 'quickstack_execute_read_operation', + 'quickstack_search_operations', + ]); + + const searchResponse = await handler.fetch(jsonRpcRequest({ + method: 'tools/call', + toolName: 'quickstack_search_operations', + apiKey, + params: { name: 'quickstack_search_operations', arguments: { detail: 'summary', limit: 100 } }, + }), options); + const searchBody = await searchResponse.json(); + const operations = searchBody.result.structuredContent.operations as Array<{ operationId: string }>; + + expect(operations.some((operation) => operation.operationId === 'listApps')).toBe(true); + expect(operations.some((operation) => operation.operationId === 'saveAgent')).toBe(false); + }, 120_000); + it('executes a real REST operation through the MCP handler', async () => { const apiKey = await createAdminApiKey(); const handler = createQuickStackMcpHandler({ app: v1Api, resolveAuthInfo: resolveQuickStackAuthInfo }); diff --git a/src/app/api/mcp/route.ts b/src/app/api/mcp/route.ts index ae04ed67..4959dd67 100644 --- a/src/app/api/mcp/route.ts +++ b/src/app/api/mcp/route.ts @@ -12,12 +12,13 @@ const mcpHandler = createQuickStackMcpHandler({ }); async function handleRequest(request: Request): Promise { - const [canaryEnabled, enabled] = await Promise.all([ + const [canaryEnabled, mode, sendSecrets] = await Promise.all([ paramService.getBoolean(ParamService.USE_CANARY_CHANNEL), - paramService.getBoolean(ParamService.MCP_SERVER_ENABLED), + paramService.getMcpServerMode(), + paramService.getMcpSendSecrets(), ]); - if (!canaryEnabled || !enabled) { + if (!canaryEnabled || mode === 'off') { return jsonRpcErrorResponse({ code: MCP_ERROR_CODES.SERVER_DISABLED, message: 'MCP server is disabled.', @@ -25,7 +26,7 @@ async function handleRequest(request: Request): Promise { }); } - return mcpHandler.fetch(request); + return mcpHandler.fetch(request, { mode, sendSecrets }); } // The MCP 2026-07-28 transport is stateless: only POST is supported. diff --git a/src/app/api/mcp/route.unit.spec.ts b/src/app/api/mcp/route.unit.spec.ts index 67b7c389..24a9f7dc 100644 --- a/src/app/api/mcp/route.unit.spec.ts +++ b/src/app/api/mcp/route.unit.spec.ts @@ -1,11 +1,21 @@ const routeMocks = vi.hoisted(() => ({ getBoolean: vi.fn(), + getMcpServerMode: vi.fn(), + getMcpSendSecrets: vi.fn(), mcpFetch: vi.fn(), })); vi.mock('@/server/services/param.service', () => ({ - default: { getBoolean: routeMocks.getBoolean }, - ParamService: { MCP_SERVER_ENABLED: 'mcpServerEnabled', USE_CANARY_CHANNEL: 'useCanaryChannel' }, + default: { + getBoolean: routeMocks.getBoolean, + getMcpServerMode: routeMocks.getMcpServerMode, + getMcpSendSecrets: routeMocks.getMcpSendSecrets, + }, + ParamService: { + USE_CANARY_CHANNEL: 'useCanaryChannel', + MCP_SERVER_MODE: 'mcpServerMode', + MCP_SEND_SECRETS: 'mcpSendSecrets', + }, })); vi.mock('@/server/api/v1/api-index', () => ({ v1Api: {} })); @@ -18,16 +28,19 @@ vi.mock('@/server/mcp/mcp-handler', () => ({ import { POST } from './route'; -function setParams(values: { canary: boolean; mcp: boolean }) { +function setParams(values: { + canary: boolean; + mode: 'off' | 'readonly' | 'readwrite'; + sendSecrets?: boolean; +}) { routeMocks.getBoolean.mockImplementation(async (name: string) => { if (name === 'useCanaryChannel') { return values.canary; } - if (name === 'mcpServerEnabled') { - return values.mcp; - } return undefined; }); + routeMocks.getMcpServerMode.mockResolvedValue(values.mode); + routeMocks.getMcpSendSecrets.mockResolvedValue(values.sendSecrets ?? false); } describe('MCP route enablement', () => { @@ -35,8 +48,8 @@ describe('MCP route enablement', () => { vi.resetAllMocks(); }); - it('rejects requests when the MCP server is disabled', async () => { - setParams({ canary: true, mcp: false }); + it('rejects requests when the MCP server mode is off', async () => { + setParams({ canary: true, mode: 'off' }); const response = await POST(new Request('http://localhost/api/mcp', { method: 'POST' })); @@ -45,7 +58,7 @@ describe('MCP route enablement', () => { }); it('rejects requests when the canary channel is disabled', async () => { - setParams({ canary: false, mcp: true }); + setParams({ canary: false, mode: 'readwrite' }); const response = await POST(new Request('http://localhost/api/mcp', { method: 'POST' })); @@ -53,13 +66,29 @@ describe('MCP route enablement', () => { expect(routeMocks.mcpFetch).not.toHaveBeenCalled(); }); - it('delegates to the MCP handler when canary and the MCP server are enabled', async () => { - setParams({ canary: true, mcp: true }); + it('passes the read-only mode to the MCP handler', async () => { + setParams({ canary: true, mode: 'readonly', sendSecrets: false }); + routeMocks.mcpFetch.mockResolvedValue(new Response('handled', { status: 200 })); + + const response = await POST(new Request('http://localhost/api/mcp', { method: 'POST' })); + + expect(response.status).toBe(200); + expect(routeMocks.mcpFetch).toHaveBeenCalledWith(expect.any(Request), { + mode: 'readonly', + sendSecrets: false, + }); + }); + + it('passes the read-write mode and secret toggle to the MCP handler', async () => { + setParams({ canary: true, mode: 'readwrite', sendSecrets: true }); routeMocks.mcpFetch.mockResolvedValue(new Response('handled', { status: 200 })); const response = await POST(new Request('http://localhost/api/mcp', { method: 'POST' })); expect(response.status).toBe(200); - expect(routeMocks.mcpFetch).toHaveBeenCalledTimes(1); + expect(routeMocks.mcpFetch).toHaveBeenCalledWith(expect.any(Request), { + mode: 'readwrite', + sendSecrets: true, + }); }); }); diff --git a/src/app/settings/actions.ts b/src/app/settings/actions.ts index 5e700522..412f1a79 100644 --- a/src/app/settings/actions.ts +++ b/src/app/settings/actions.ts @@ -35,6 +35,7 @@ import { BuildSettingsModel, buildSettingsZodModel } from "@/shared/model/build- import qsAuthProxyService from "@/server/services/qs-auth-proxy.service"; import clusterAddonRegistryService from "@/server/services/addons/cluster-addon-registry.service"; import { ServiceException } from "@/shared/model/service.exception.model"; +import { MCP_SERVER_MODE_LABELS, McpServerMode, mcpServerModeZodModel } from "@/shared/model/mcp-settings.model"; import { randomUUID } from "crypto"; export const saveBuildSettings = async (prevState: any, inputData: BuildSettingsModel) => @@ -272,14 +273,35 @@ export const setOpenApiSpecEnabled = async (enabled: boolean) => return new SuccessActionResult(undefined, `Turned ${enabled ? 'on' : 'off'} OpenAPI spec access.`); }); -export const setMcpServerEnabled = async (enabled: boolean) => +export const setMcpServerMode = async (mode: McpServerMode) => simpleAction(async () => { await getAdminUserSession(); + + const parsedMode = mcpServerModeZodModel.safeParse(mode); + if (!parsedMode.success) { + throw new ServiceException('Invalid MCP server mode.'); + } + + await paramService.save({ + name: ParamService.MCP_SERVER_MODE, + value: parsedMode.data, + }); + // Keep the legacy flag in sync so an older QuickStack build still sees on/off. await paramService.save({ name: ParamService.MCP_SERVER_ENABLED, - value: enabled ? 'true' : 'false' + value: parsedMode.data === 'off' ? 'false' : 'true', + }); + return new SuccessActionResult(undefined, `MCP server set to ${MCP_SERVER_MODE_LABELS[parsedMode.data]}.`); + }); + +export const setMcpSendSecrets = async (enabled: boolean) => + simpleAction(async () => { + await getAdminUserSession(); + await paramService.save({ + name: ParamService.MCP_SEND_SECRETS, + value: enabled ? 'true' : 'false', }); - return new SuccessActionResult(undefined, `Turned ${enabled ? 'on' : 'off'} the MCP server.`); + return new SuccessActionResult(undefined, `MCP secrets ${enabled ? 'enabled' : 'disabled'}.`); }); export const setRegistryStorageLocation = async (prevState: any, inputData: RegistryStorageLocationSettingsModel) => diff --git a/src/app/settings/developer/mcp/mcp-settings.tsx b/src/app/settings/developer/mcp/mcp-settings.tsx index 6204cc5d..3942f8fa 100644 --- a/src/app/settings/developer/mcp/mcp-settings.tsx +++ b/src/app/settings/developer/mcp/mcp-settings.tsx @@ -7,11 +7,14 @@ import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Label } from '@/components/ui/label'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { Switch } from '@/components/ui/switch'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import LoadingSpinner from '@/components/ui/loading-spinner'; import { Toast } from '@/frontend/utils/toast.utils'; -import { setMcpServerEnabled } from '../../actions'; +import { MCP_SERVER_MODE_LABELS, McpServerMode, mcpServerModeZodModel } from '@/shared/model/mcp-settings.model'; +import { McpSettingsUtils } from '@/shared/utils/mcp.utils'; +import { setMcpSendSecrets, setMcpServerMode } from '../../actions'; function Snippet({ value }: { value: string }) { return ( @@ -44,8 +47,9 @@ function Step({ title, children }: { title: string; children: ReactNode }) { ); } -export default function McpSettings({ mcpEnabled }: { mcpEnabled: boolean }) { - const [isEnabled, setIsEnabled] = useState(mcpEnabled); +export default function McpSettings({ mcpMode, sendSecrets }: { mcpMode: McpServerMode; sendSecrets: boolean }) { + const [mode, setMode] = useState(mcpMode); + const [sendSecretsViaMcp, setSendSecretsViaMcp] = useState(sendSecrets); const [loading, setLoading] = useState(false); const [origin, setOrigin] = useState(''); @@ -54,20 +58,44 @@ export default function McpSettings({ mcpEnabled }: { mcpEnabled: boolean }) { }, []); const mcpUrl = `${origin || 'https://'}/api/mcp`; + const isEnabled = McpSettingsUtils.isMcpServerEnabled(mode); - const handleToggle = async (checked: boolean) => { - const previousState = isEnabled; + const handleModeChange = async (value: string | null) => { + const parsedMode = mcpServerModeZodModel.safeParse(value); + if (!parsedMode.success) { + return; + } + + const previousMode = mode; try { setLoading(true); - setIsEnabled(checked); + setMode(parsedMode.data); await Toast.fromAction( - () => setMcpServerEnabled(checked), - `MCP server ${checked ? 'enabled' : 'disabled'}`, - `Updating MCP server...` + () => setMcpServerMode(parsedMode.data), + `MCP server set to ${MCP_SERVER_MODE_LABELS[parsedMode.data]}`, + 'Updating MCP server...' ); } catch { - setIsEnabled(previousState); + setMode(previousMode); + } finally { + setLoading(false); + } + }; + + const handleSendSecretsToggle = async (checked: boolean) => { + const previousState = sendSecretsViaMcp; + try { + setLoading(true); + setSendSecretsViaMcp(checked); + + await Toast.fromAction( + () => setMcpSendSecrets(checked), + `MCP secrets ${checked ? 'enabled' : 'disabled'}`, + 'Updating MCP secret handling...' + ); + } catch { + setSendSecretsViaMcp(previousState); } finally { setLoading(false); } @@ -117,11 +145,10 @@ Authorization: Bearer Canary feature: secrets can flow through MCP - The MCP server is a canary feature and still in active developement. Tool arguments and results are passed to the AI host and model. - Because the MCP server is currently built ontop of the QuickStack REST API, read operations can return secrets in plaintext, including App environment variables, build arguments, - Git tokens, container registry credentials and App basic-auth passwords. Write operations can send the - same values. Only connect trusted clients and use a REST API - Key from a user with the minimum required permissions. + The MCP server is a canary feature and still in active development. Tool arguments and results are passed to the AI host and model. + Read responses redact secret fields unless Send secrets is enabled. Write tool arguments are still sent to QuickStack, + so never put secrets into a prompt that should not reach the AI host. Only connect trusted clients and use a REST API Key + from a user with the minimum required permissions. @@ -136,13 +163,46 @@ Authorization: Bearer Codex and GitHub Copilot can then act on QuickStack through the REST API using a REST API Key. - -
+ +
+
+ + + {loading && } +
+

+ Read only exposes the search and read tools. + Read & write also exposes the mutating tool. +

+
+ +
- - + +
- {loading && } +

+ When off, fields marked as secret (environment variables, build arguments, registry passwords, + Git tokens, SSH private keys, basic-auth passwords, webhook IDs, health-check headers and + LLM admin keys) are removed from MCP results. Temporary sandbox access URLs and API tokens + are also redacted. The normal REST API is not affected. +

@@ -152,10 +212,10 @@ Authorization: Bearer Connection Use the endpoint URL below and authenticate with a REST API Key created under - {' '}Settings → Account & Access → API Keys. QuickStack exposes three - namespaced tools: quickstack_search_operations to - discover operations, quickstack_execute_read_operation for - read-only calls and quickstack_execute_operation for + {' '}Settings → Account & Access → API Keys. QuickStack exposes + {' '}quickstack_search_operations to discover operations and + {' '}quickstack_execute_read_operation for read-only calls. + Read & write mode adds quickstack_execute_operation for mutating calls. diff --git a/src/app/settings/developer/mcp/page.tsx b/src/app/settings/developer/mcp/page.tsx index 8107438a..2d6fee10 100644 --- a/src/app/settings/developer/mcp/page.tsx +++ b/src/app/settings/developer/mcp/page.tsx @@ -10,8 +10,11 @@ export default async function McpSettingsPage() { if (!canaryEnabled) { notFound() } - const mcpEnabled = await paramService.getBoolean(ParamService.MCP_SERVER_ENABLED) + const [mcpMode, sendSecrets] = await Promise.all([ + paramService.getMcpServerMode(), + paramService.getMcpSendSecrets(), + ]) return - + } diff --git a/src/server/mcp/mcp-handler.ts b/src/server/mcp/mcp-handler.ts index 6de3b5ee..d63c24ae 100644 --- a/src/server/mcp/mcp-handler.ts +++ b/src/server/mcp/mcp-handler.ts @@ -9,6 +9,8 @@ import { jsonRpcErrorResponse, MCP_ERROR_CODES } from './mcp-errors'; import mcpServerFactory from './mcp-server.factory'; import operationRegistry from './operation-registry'; import type { McpSourceApp, OperationDescriptor } from './operation-registry'; +import { McpServerMode } from '@/shared/model/mcp-settings.model'; +import { McpSettingsUtils } from '@/shared/utils/mcp.utils'; export type QuickStackMcpHandlerOptions = { app: McpSourceApp; @@ -17,12 +19,20 @@ export type QuickStackMcpHandlerOptions = { allowedHosts?: string[]; }; +export type QuickStackMcpRequestOptions = { + mode?: McpServerMode; + sendSecrets?: boolean; +}; + export type QuickStackMcpHandler = { - fetch: (request: Request) => Promise; + fetch: (request: Request, options?: QuickStackMcpRequestOptions) => Promise; close: () => Promise; operations: OperationDescriptor[]; }; +const MCP_SERVER_MODE_EXTRA_KEY = 'mcpServerMode'; +const MCP_SEND_SECRETS_EXTRA_KEY = 'mcpSendSecrets'; + function unauthorizedResponse(): Response { return jsonRpcErrorResponse({ code: MCP_ERROR_CODES.UNAUTHORIZED, @@ -37,11 +47,23 @@ export function createQuickStackMcpHandler(options: QuickStackMcpHandlerOptions) const operations = operationRegistry.build(app); const handler = createMcpHandler( - ({ authInfo }) => mcpServerFactory.build({ app, operations, authInfo }), + ({ authInfo }) => { + const extra = authInfo?.extra ?? {}; + const configuredMode = extra[MCP_SERVER_MODE_EXTRA_KEY]; + const mode = McpSettingsUtils.isMcpServerMode(configuredMode) ? configuredMode : 'readwrite'; + + return mcpServerFactory.build({ + app, + operations, + authInfo, + mode, + sendSecrets: extra[MCP_SEND_SECRETS_EXTRA_KEY] === true, + }); + }, { responseMode: 'json' } ); - const fetch = async (request: Request): Promise => { + const fetch = async (request: Request, options?: QuickStackMcpRequestOptions): Promise => { if (allowedHosts && allowedHosts.length > 0) { const hostRejected = hostHeaderValidationResponse(request, allowedHosts); if (hostRejected) { @@ -66,7 +88,16 @@ export function createQuickStackMcpHandler(options: QuickStackMcpHandlerOptions) return unauthorizedResponse(); } - return handler.fetch(request, { authInfo }); + return handler.fetch(request, { + authInfo: { + ...authInfo, + extra: { + ...(authInfo.extra ?? {}), + [MCP_SERVER_MODE_EXTRA_KEY]: options?.mode ?? 'readwrite', + [MCP_SEND_SECRETS_EXTRA_KEY]: options?.sendSecrets ?? false, + }, + }, + }); }; return { fetch, close: () => handler.close(), operations }; diff --git a/src/server/mcp/mcp-handler.unit.spec.ts b/src/server/mcp/mcp-handler.unit.spec.ts index f47e91b1..bb951bd8 100644 --- a/src/server/mcp/mcp-handler.unit.spec.ts +++ b/src/server/mcp/mcp-handler.unit.spec.ts @@ -2,7 +2,7 @@ import { Elysia } from 'elysia'; import { z } from 'zod'; import type { AuthInfo } from '@modelcontextprotocol/server'; import { createQuickStackMcpHandler } from './mcp-handler'; -import type { QuickStackMcpHandler } from './mcp-handler'; +import type { QuickStackMcpHandler, QuickStackMcpRequestOptions } from './mcp-handler'; const VALID_TOKEN = 'valid-token'; @@ -54,6 +54,46 @@ function buildFixtureApp() { response: { 200: z.object({ deploymentId: z.string() }), 401: unauthorizedSchema }, detail: { operationId: 'deployApp', summary: 'Deploy app', description: 'Starts a build and rollout for the app.', tags: ['Apps'] }, }) + .get('/secret-project', ({ authenticated }) => { + if (!authenticated) return unauthorized(); + return { + id: 'p1', + name: 'Secret project', + envVars: 'API_KEY=secret', + buildArgs: 'NPM_TOKEN=secret', + containerRegistryPassword: 'registry-password', + gitToken: 'git-token', + appBasicAuths: [{ username: 'user', password: 'basic-password' }], + appGitSshKey: { publicKey: 'ssh-rsa', encryptedPrivateKey: 'encrypted-private-key' }, + llmGateway: { encryptedAdminKey: 'admin-key' }, + visible: 'ok', + }; + }, { + response: { + 200: z.object({ + id: z.string(), + name: z.string(), + envVars: z.string().meta({ secret: true }), + buildArgs: z.string().meta({ secret: true }), + containerRegistryPassword: z.string().nullish().meta({ secret: true }), + gitToken: z.string().nullish().meta({ secret: true }), + appBasicAuths: z.array(z.object({ + username: z.string(), + password: z.string().meta({ secret: true }), + })), + appGitSshKey: z.object({ + publicKey: z.string(), + encryptedPrivateKey: z.string().meta({ secret: true }), + }), + llmGateway: z.object({ + encryptedAdminKey: z.string().meta({ secret: true }), + }), + visible: z.string(), + }), + 401: unauthorizedSchema, + }, + detail: { operationId: 'getSecretProject', summary: 'Get secret project', tags: ['Testing'] }, + }) .get('/huge', () => ({ blob: 'x'.repeat(100_000) }), { response: { 200: z.object({ blob: z.string() }) }, detail: { operationId: 'getHugePayload', summary: 'Get huge payload', tags: ['Testing'] }, @@ -147,17 +187,22 @@ function jsonRpcRequest(options: { }); } -async function listTools(handler: QuickStackMcpHandler) { - const response = await handler.fetch(jsonRpcRequest({ method: 'tools/list' })); +async function listTools(handler: QuickStackMcpHandler, options?: QuickStackMcpRequestOptions) { + const response = await handler.fetch(jsonRpcRequest({ method: 'tools/list' }), options); return { status: response.status, body: await response.json() }; } -async function callTool(handler: QuickStackMcpHandler, name: string, args: Record) { +async function callTool( + handler: QuickStackMcpHandler, + name: string, + args: Record, + options?: QuickStackMcpRequestOptions +) { const response = await handler.fetch(jsonRpcRequest({ method: 'tools/call', toolName: name, params: { name, arguments: args }, - })); + }), options); return { status: response.status, body: await response.json() }; } @@ -183,6 +228,98 @@ describe('QuickStack MCP handler', () => { expect(body.result.instructions).toContain('ask the user for confirmation'); }); + it('hides the write tool and write operations in read-only mode', async () => { + const options: QuickStackMcpRequestOptions = { mode: 'readonly', sendSecrets: false }; + const { body: toolsBody } = await listTools(buildHandler(), options); + const toolNames = toolsBody.result.tools.map((tool: { name: string }) => tool.name).sort(); + + expect(toolNames).toEqual([ + 'quickstack_execute_read_operation', + 'quickstack_search_operations', + ]); + + const { body: searchBody } = await callTool( + buildHandler(), + 'quickstack_search_operations', + { detail: 'summary' }, + options + ); + const ids = searchBody.result.structuredContent.operations + .map((operation: { operationId: string }) => operation.operationId); + + expect(ids).toContain('listProjects'); + expect(ids).not.toContain('saveProject'); + + const { body: writeBody } = await callTool( + buildHandler(), + 'quickstack_execute_operation', + { operationId: 'saveProject', body: { name: 'blocked' } }, + options + ); + expect(writeBody.result?.isError || writeBody.error).toBeTruthy(); + }); + + it('publishes read-only instructions and mentions redacted secrets', async () => { + const response = await buildHandler().fetch( + jsonRpcRequest({ method: 'server/discover' }), + { mode: 'readonly', sendSecrets: false } + ); + const body = await response.json(); + + expect(body.result.instructions).toContain('read-only'); + expect(body.result.instructions).toContain('Secret fields are redacted'); + expect(body.result.instructions).not.toContain('Before calling quickstack_execute_operation'); + }); + + it('redacts secret response fields by default', async () => { + const { body } = await callTool(buildHandler(), 'quickstack_execute_read_operation', { + operationId: 'getSecretProject', + }); + + expect(body.result.isError).not.toBe(true); + expect(body.result.structuredContent.result).toEqual({ + id: 'p1', + name: 'Secret project', + appBasicAuths: [{ username: 'user' }], + appGitSshKey: { publicKey: 'ssh-rsa' }, + llmGateway: {}, + visible: 'ok', + }); + }); + + it('returns secret response fields when the secret toggle is enabled', async () => { + const { body } = await callTool( + buildHandler(), + 'quickstack_execute_read_operation', + { operationId: 'getSecretProject' }, + { mode: 'readwrite', sendSecrets: true } + ); + + expect(body.result.structuredContent.result).toMatchObject({ + envVars: 'API_KEY=secret', + buildArgs: 'NPM_TOKEN=secret', + containerRegistryPassword: 'registry-password', + gitToken: 'git-token', + appBasicAuths: [{ username: 'user', password: 'basic-password' }], + appGitSshKey: { publicKey: 'ssh-rsa', encryptedPrivateKey: 'encrypted-private-key' }, + llmGateway: { encryptedAdminKey: 'admin-key' }, + }); + }); + + it('does not redact the normal REST response', async () => { + const response = await buildFixtureApp().handle(new Request('http://localhost/api/v1/secret-project', { + headers: { authorization: `Bearer ${VALID_TOKEN}` }, + })); + const body = await response.json(); + + expect(body).toMatchObject({ + envVars: 'API_KEY=secret', + buildArgs: 'NPM_TOKEN=secret', + containerRegistryPassword: 'registry-password', + gitToken: 'git-token', + }); + }); + it('rejects a request with an invalid REST API Key', async () => { const response = await buildHandler().fetch(jsonRpcRequest({ method: 'tools/list', token: 'wrong-token' })); expect(response.status).toBe(401); diff --git a/src/server/mcp/mcp-secret-redaction.ts b/src/server/mcp/mcp-secret-redaction.ts new file mode 100644 index 00000000..2cfa859c --- /dev/null +++ b/src/server/mcp/mcp-secret-redaction.ts @@ -0,0 +1,204 @@ +type JsonSchema = Record; + +/** + * Removes every response field whose schema carries `.meta({ secret: true })`. + * + * The MCP operation registry exposes each REST response as JSON Schema, so the + * redaction follows the exact response shape, including arrays, unions and + * nullable wrappers. It is intentionally conservative: when multiple union + * variants match, all matching variants are applied. + */ +export class McpSecretRedactionUtils { + + redact(value: unknown, schema: JsonSchema | undefined): unknown { + if (!schema) { + return value; + } + return McpSecretRedactionUtils.redactValue(value, schema, schema); + } + + private static redactValue(value: unknown, schema: JsonSchema | undefined, root: JsonSchema): unknown { + if (!schema || typeof schema !== 'object') { + return value; + } + if (schema.secret === true) { + return undefined; + } + + const resolved = this.resolve(schema, root); + if (!resolved) { + return value; + } + if (resolved.secret === true) { + return undefined; + } + + let workingValue = value; + if (Array.isArray(resolved.allOf)) { + for (const part of resolved.allOf) { + workingValue = this.redactValue(workingValue, part as JsonSchema, root); + } + } + + const branches = this.unionBranches(resolved); + if (branches.length > 0) { + const matching = branches.filter((branch) => this.matches(workingValue, branch, root)); + const selected = matching.length > 0 ? matching : [branches[0] as JsonSchema]; + + for (const branch of selected) { + workingValue = this.redactValue(workingValue, branch, root); + } + } + + if (Array.isArray(workingValue)) { + const items = resolved.items; + if (!items) { + return workingValue; + } + if (Array.isArray(items)) { + return workingValue.map((item, index) => this.redactValue(item, items[index] as JsonSchema | undefined, root)); + } + return workingValue.map((item) => this.redactValue(item, items as JsonSchema, root)); + } + + if (workingValue !== null && typeof workingValue === 'object') { + const properties = this.properties(resolved); + const result = { ...(workingValue as Record) }; + const knownKeys = new Set(Object.keys(properties)); + + for (const [key, propertySchema] of Object.entries(properties)) { + if (!(key in result)) { + continue; + } + if (propertySchema.secret === true) { + delete result[key]; + continue; + } + + const redacted = this.redactValue(result[key], propertySchema, root); + if (redacted === undefined) { + delete result[key]; + } else { + result[key] = redacted; + } + } + + const additionalProperties = resolved.additionalProperties; + if (additionalProperties && typeof additionalProperties === 'object' && !Array.isArray(additionalProperties)) { + for (const key of Object.keys(result)) { + if (!knownKeys.has(key)) { + result[key] = this.redactValue(result[key], additionalProperties as JsonSchema, root); + } + } + } + + return result; + } + + return workingValue; + } + + private static resolve(schema: JsonSchema, root: JsonSchema): JsonSchema | undefined { + let current: JsonSchema | undefined = schema; + const seen = new Set(); + + while (current && typeof current.$ref === 'string') { + if (seen.has(current.$ref)) { + return current; + } + seen.add(current.$ref); + + const target = this.resolveRef(current.$ref, root); + if (!target) { + return current; + } + current = target; + } + + return current; + } + + private static resolveRef(ref: string, root: JsonSchema): JsonSchema | undefined { + if (!ref.startsWith('#/')) { + return undefined; + } + + let current: unknown = root; + for (const rawSegment of ref.slice(2).split('/')) { + const segment = rawSegment.replace(/~1/g, '/').replace(/~0/g, '~'); + if (!current || typeof current !== 'object') { + return undefined; + } + current = (current as Record)[segment]; + } + + return current && typeof current === 'object' && !Array.isArray(current) + ? current as JsonSchema + : undefined; + } + + private static unionBranches(schema: JsonSchema): JsonSchema[] { + for (const key of ['anyOf', 'oneOf']) { + const branches = schema[key]; + if (Array.isArray(branches)) { + return branches.filter((branch): branch is JsonSchema => + Boolean(branch) && typeof branch === 'object' && !Array.isArray(branch) + ); + } + } + return []; + } + + private static matches(value: unknown, schema: JsonSchema, root: JsonSchema): boolean { + const resolved = this.resolve(schema, root); + if (!resolved) { + return true; + } + if ('const' in resolved) { + return resolved.const === value; + } + if (Array.isArray(resolved.enum)) { + return resolved.enum.includes(value); + } + + const nestedBranches = this.unionBranches(resolved); + if (nestedBranches.length > 0) { + return nestedBranches.some((branch) => this.matches(value, branch, root)); + } + + const type = resolved.type; + const types = Array.isArray(type) ? type : [type]; + + return types.some((candidate) => { + switch (candidate) { + case 'null': + return value === null; + case 'array': + return Array.isArray(value); + case 'object': + return value !== null && typeof value === 'object' && !Array.isArray(value); + case 'string': + return typeof value === 'string'; + case 'number': + return typeof value === 'number'; + case 'integer': + return typeof value === 'number' && Number.isInteger(value); + case 'boolean': + return typeof value === 'boolean'; + default: + return true; + } + }); + } + + private static properties(schema: JsonSchema): Record { + const properties = schema.properties; + if (!properties || typeof properties !== 'object' || Array.isArray(properties)) { + return {}; + } + return properties as Record; + } +} + +const mcpSecretRedactionUtils = new McpSecretRedactionUtils(); +export default mcpSecretRedactionUtils; diff --git a/src/server/mcp/mcp-secret-redaction.unit.spec.ts b/src/server/mcp/mcp-secret-redaction.unit.spec.ts new file mode 100644 index 00000000..a8a653c6 --- /dev/null +++ b/src/server/mcp/mcp-secret-redaction.unit.spec.ts @@ -0,0 +1,122 @@ +import mcpSecretRedactionUtils from './mcp-secret-redaction'; + +type JsonSchema = Record; + +describe('McpSecretRedactionUtils', () => { + it('redacts nested secrets through arrays and local refs', () => { + const schema: JsonSchema = { + type: 'object', + properties: { + apps: { + type: 'array', + items: { $ref: '#/$defs/App' }, + }, + visible: { type: 'string' }, + }, + $defs: { + App: { + type: 'object', + properties: { + name: { type: 'string' }, + envVars: { type: 'string', secret: true }, + nested: { + type: 'object', + properties: { + token: { type: 'string', secret: true }, + visible: { type: 'string' }, + }, + }, + }, + }, + }, + }; + + const result = mcpSecretRedactionUtils.redact({ + apps: [{ + name: 'demo', + envVars: 'API_KEY=secret', + nested: { token: 'nested-secret', visible: 'ok' }, + }], + visible: 'root', + }, schema); + + expect(result).toEqual({ + apps: [{ + name: 'demo', + nested: { visible: 'ok' }, + }], + visible: 'root', + }); + }); + + it('redacts secrets in union and allOf schemas', () => { + const schema: JsonSchema = { + anyOf: [ + { + type: 'object', + properties: { + items: { type: 'array', items: { type: 'string' } }, + }, + }, + { + type: 'object', + properties: { + items: { type: 'array', items: { type: 'string' } }, + total: { type: 'number' }, + }, + }, + ], + allOf: [ + { + type: 'object', + properties: { + gitToken: { type: 'string', secret: true }, + }, + }, + ], + }; + + const result = mcpSecretRedactionUtils.redact({ + items: ['a'], + total: 1, + gitToken: 'secret', + }, schema); + + expect(result).toEqual({ items: ['a'], total: 1 }); + }); + + it('redacts secrets declared inside a union branch', () => { + const result = mcpSecretRedactionUtils.redact({ + gitToken: 'secret', + visible: 'ok', + }, { + type: 'object', + properties: { + gitToken: { + anyOf: [ + { type: 'string', secret: true }, + { type: 'null' }, + ], + }, + visible: { type: 'string' }, + }, + }); + + expect(result).toEqual({ visible: 'ok' }); + }); + + it('keeps visible fields when no secret metadata exists', () => { + const result = mcpSecretRedactionUtils.redact({ + name: 'demo', + envVars: 'API_KEY=secret', + }, { + type: 'object', + properties: { + name: { type: 'string' }, + envVars: { type: 'string' }, + }, + }); + + expect(result).toEqual({ name: 'demo', envVars: 'API_KEY=secret' }); + }); +}); diff --git a/src/server/mcp/mcp-server.factory.ts b/src/server/mcp/mcp-server.factory.ts index b428315d..45853743 100644 --- a/src/server/mcp/mcp-server.factory.ts +++ b/src/server/mcp/mcp-server.factory.ts @@ -3,7 +3,9 @@ import type { AuthInfo } from '@modelcontextprotocol/server'; import { z } from 'zod'; import packageJson from '../../../package.json'; import operationExecutor from './operation-executor'; +import mcpSecretRedactionUtils from './mcp-secret-redaction'; import type { McpSourceApp, OperationDescriptor } from './operation-registry'; +import type { McpServerMode } from '@/shared/model/mcp-settings.model'; export const MCP_TOOL_NAMES = { search: 'quickstack_search_operations', @@ -82,14 +84,32 @@ const executeOutputSchema = z.object({ hint: z.string().optional(), }); -const SERVER_INSTRUCTIONS = [ - 'QuickStack is a self-hosted platform for deploying Apps and Agents on Kubernetes.', - `Workflow: call ${SEARCH_TOOL} to find the operation id, call it again with detail "full" to read the exact input schema, then run the operation with ${READ_TOOL} or ${WRITE_TOOL}.`, - `Only pass a readOnly operation to ${READ_TOOL}. Use ${WRITE_TOOL} for deploys, deletes, saves and commands.`, - `Before calling ${WRITE_TOOL}, summarize the intended change and ask the user for confirmation. Never chain destructive writes without explicit approval.`, - 'deployApp returns a deploymentId immediately; poll listAppDeployments or getAppDeployment until it reports success or failure. deployAgent applies CONTAINER configs synchronously; GIT builds continue asynchronously in the QuickStack UI, so poll listAgentSandboxes before starting a sandbox.', - 'Tool responses are truncated when they exceed the size limit. Narrow the request with search filters, list limit/cursor or a smaller lines value.', -].join(' '); +const SECRET_REDACTION_INSTRUCTION = 'Secret fields are redacted from tool results: environment variables, build arguments, container registry passwords, Git tokens, SSH private keys, basic-auth passwords, webhook IDs, health-check headers and LLM admin keys may be absent. Do not assume they are empty or unchanged.'; + +function buildServerInstructions(mode: McpServerMode, sendSecrets: boolean): string { + const runStep = mode === 'readonly' + ? `then run the read-only operation with ${READ_TOOL}.` + : `then run the operation with ${READ_TOOL} or ${WRITE_TOOL}.`; + const instructions = [ + 'QuickStack is a self-hosted platform for deploying Apps and Agents on Kubernetes.', + `Workflow: call ${SEARCH_TOOL} to find the operation id, call it again with detail "full" to read the exact input schema, ${runStep}`, + `Only pass a readOnly operation to ${READ_TOOL}.`, + 'deployApp returns a deploymentId immediately; poll listAppDeployments or getAppDeployment until it reports success or failure. deployAgent applies CONTAINER configs synchronously; GIT builds continue asynchronously in the QuickStack UI, so poll listAgentSandboxes before starting a sandbox.', + 'Tool responses are truncated when they exceed the size limit. Narrow the request with search filters, list limit/cursor or a smaller lines value.', + ]; + + if (mode === 'readonly') { + instructions.push(`This server is read-only. Write operations are hidden and ${WRITE_TOOL} is unavailable.`); + } else { + instructions.push(`Use ${WRITE_TOOL} for deploys, deletes, saves and commands. Before calling ${WRITE_TOOL}, summarize the intended change and ask the user for confirmation. Never chain destructive writes without explicit approval.`); + } + + if (!sendSecrets) { + instructions.push(SECRET_REDACTION_INSTRUCTION); + } + + return instructions.join(' '); +} const SEARCH_TOOL_DESCRIPTION = [ 'Search the QuickStack operations catalog.', @@ -130,6 +150,8 @@ type RankedOperation = { export type McpServerBuildOptions = { app: McpSourceApp; operations: OperationDescriptor[]; + mode: McpServerMode; + sendSecrets: boolean; authInfo?: AuthInfo; version?: string; }; @@ -139,20 +161,32 @@ class McpServerFactory { build(options: McpServerBuildOptions): McpServer { const server = new McpServer( { name: SERVER_NAME, version: options.version ?? SERVER_VERSION }, - { instructions: SERVER_INSTRUCTIONS } + { instructions: buildServerInstructions(options.mode, options.sendSecrets) } ); - this.registerSearchOperation(server, options.operations); - this.registerReadOperation(server, options); - this.registerWriteOperation(server, options); + + const availableOperations = options.mode === 'readonly' + ? options.operations.filter((operation) => operation.readOnly) + : options.operations; + const availableOptions = { ...options, operations: availableOperations }; + + this.registerSearchOperation(server, availableOperations, options.mode); + this.registerReadOperation(server, availableOptions); + if (options.mode === 'readwrite') { + this.registerWriteOperation(server, availableOptions); + } return server; } - private registerSearchOperation(server: McpServer, operations: OperationDescriptor[]): void { + private registerSearchOperation(server: McpServer, operations: OperationDescriptor[], mode: McpServerMode): void { + const modeDescription = mode === 'readonly' + ? 'This server is read-only; only read-only operations are listed.' + : ''; + server.registerTool( SEARCH_TOOL, { title: 'Search QuickStack operations', - description: SEARCH_TOOL_DESCRIPTION, + description: [SEARCH_TOOL_DESCRIPTION, modeDescription].filter(Boolean).join(' '), inputSchema: searchInputSchema, outputSchema: searchOutputSchema, annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: false }, @@ -186,11 +220,15 @@ class McpServerFactory { } private registerReadOperation(server: McpServer, options: McpServerBuildOptions): void { + const secretDescription = options.sendSecrets + ? 'Secret fields are included in responses.' + : 'Secret fields are redacted from responses.'; + server.registerTool( READ_TOOL, { title: 'Execute read-only QuickStack operation', - description: READ_TOOL_DESCRIPTION, + description: `${READ_TOOL_DESCRIPTION} ${secretDescription}`, inputSchema: readInputSchema, outputSchema: executeOutputSchema, annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: false }, @@ -204,11 +242,15 @@ class McpServerFactory { } private registerWriteOperation(server: McpServer, options: McpServerBuildOptions): void { + const secretDescription = options.sendSecrets + ? 'Secret fields are included in responses.' + : 'Secret fields are redacted from responses.'; + server.registerTool( WRITE_TOOL, { title: 'Execute mutating QuickStack operation', - description: WRITE_TOOL_DESCRIPTION, + description: `${WRITE_TOOL_DESCRIPTION} ${secretDescription}`, inputSchema: writeInputSchema, outputSchema: executeOutputSchema, annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false }, @@ -251,7 +293,10 @@ class McpServerFactory { } if (execution.status >= 200 && execution.status < 300) { - return this.successResult(operation, execution.status, execution.result ?? null); + const result = options.sendSecrets + ? execution.result + : mcpSecretRedactionUtils.redact(execution.result, operation.responseSchema); + return this.successResult(operation, execution.status, result ?? null); } return this.errorResult(this.describeFailure(execution.status, execution.result)); diff --git a/src/server/services/param.service.ts b/src/server/services/param.service.ts index a6bb49e0..9639982c 100644 --- a/src/server/services/param.service.ts +++ b/src/server/services/param.service.ts @@ -4,6 +4,8 @@ import { Tags } from "../utils/cache-tag-generator.utils"; import { Parameter, Prisma } from "@prisma/client"; import { Constants } from "../../shared/utils/constants"; import { CryptoUtils } from "../utils/crypto.utils"; +import { McpServerMode } from "../../shared/model/mcp-settings.model"; +import { McpSettingsUtils } from "../../shared/utils/mcp.utils"; export class ParamService { @@ -23,12 +25,16 @@ export class ParamService { static readonly MAX_PARALLEL_BUILDS = 'maxParallelBuilds'; static readonly QS_INSTANCE_ID = 'qsInstanceId'; static readonly API_OPEN_API_SPEC_ENABLED = 'apiOpenApiSpecEnabled'; + /** Legacy on/off flag. Kept in sync for older deployments, read only for migration. */ static readonly MCP_SERVER_ENABLED = 'mcpServerEnabled'; + static readonly MCP_SERVER_MODE = 'mcpServerMode'; + static readonly MCP_SEND_SECRETS = 'mcpSendSecrets'; static readonly AGENT_JWT_SECRET = 'agentJwtSecret'; static readonly LATEST_COMPLETED_CODE_MIGRATION = 'latestCompletedCodeMigration'; static readonly FEATURE_NEW_NETWORK_POLICY_EXPLENATION = 'featureNewNetworkPolicyExplenation'; async initializeDefaults(revalidateParam = true) { + const legacyMcpEnabled = (await this.getOrUndefinedUncached(ParamService.MCP_SERVER_ENABLED))?.value === 'true'; const [instanceId, registryLocation] = await Promise.all([ this.getOrCreate(ParamService.QS_INSTANCE_ID, crypto.randomUUID(), revalidateParam), this.getOrCreate(ParamService.DISABLE_NODEPORT_ACCESS, 'false', revalidateParam), @@ -38,6 +44,8 @@ export class ParamService { this.getOrCreate(ParamService.MAX_PARALLEL_BUILDS, String(Constants.DEFAULT_MAX_PARALLEL_BUILDS), revalidateParam), this.getOrCreate(ParamService.API_OPEN_API_SPEC_ENABLED, 'false', revalidateParam), this.getOrCreate(ParamService.MCP_SERVER_ENABLED, 'false', revalidateParam), + this.getOrCreate(ParamService.MCP_SERVER_MODE, legacyMcpEnabled ? 'readwrite' : 'off', revalidateParam), + this.getOrCreate(ParamService.MCP_SEND_SECRETS, 'false', revalidateParam), ]); return { instanceId, registryLocation }; @@ -112,6 +120,20 @@ export class ParamService { return undefined; } + async getMcpServerMode(): Promise { + const configuredMode = await this.getString(ParamService.MCP_SERVER_MODE); + if (McpSettingsUtils.isMcpServerMode(configuredMode)) { + return configuredMode; + } + + const legacyEnabled = await this.getBoolean(ParamService.MCP_SERVER_ENABLED); + return legacyEnabled ? 'readwrite' : 'off'; + } + + async getMcpSendSecrets(): Promise { + return (await this.getBoolean(ParamService.MCP_SEND_SECRETS)) ?? false; + } + async getString(name: string) { const param = await this.getOrUndefined(name); if (param) { diff --git a/src/server/utils/api-response.utils.ts b/src/server/utils/api-response.utils.ts index 6214ef9e..c884814a 100644 --- a/src/server/utils/api-response.utils.ts +++ b/src/server/utils/api-response.utils.ts @@ -44,54 +44,63 @@ export class ApiUtils { mappedObject = mappedObject.catchall(ApiUtils.mapDateSchemaToStringDate(catchall)); } - return mappedObject; + return ApiUtils.withMeta(schema, mappedObject); } if (schema instanceof z.ZodArray) { - return z.array(ApiUtils.mapDateSchemaToStringDate(schema.element)); + return ApiUtils.withMeta(schema, z.array(ApiUtils.mapDateSchemaToStringDate(schema.element))); } if (schema instanceof z.ZodOptional) { - return ApiUtils.mapDateSchemaToStringDate(schema.unwrap()).optional(); + return ApiUtils.withMeta(schema, ApiUtils.mapDateSchemaToStringDate(schema.unwrap()).optional()); } if (schema instanceof z.ZodNullable) { - return ApiUtils.mapDateSchemaToStringDate(schema.unwrap()).nullable(); + return ApiUtils.withMeta(schema, ApiUtils.mapDateSchemaToStringDate(schema.unwrap()).nullable()); } if (schema instanceof z.ZodDefault) { - return ApiUtils.mapDateSchemaToStringDate(schema._def.innerType).default(schema._def.defaultValue); + return ApiUtils.withMeta(schema, ApiUtils.mapDateSchemaToStringDate(schema._def.innerType).default(schema._def.defaultValue)); } if (schema instanceof z.ZodUnion) { - return z.union((schema._def.options).map(option => ApiUtils.mapDateSchemaToStringDate(option)) as [z.ZodTypeAny, z.ZodTypeAny, ...z.ZodTypeAny[]]); + return ApiUtils.withMeta(schema, z.union((schema._def.options).map(option => ApiUtils.mapDateSchemaToStringDate(option)) as [z.ZodTypeAny, z.ZodTypeAny, ...z.ZodTypeAny[]])); } if (schema instanceof z.ZodTuple) { const mappedItems = (schema._def.items).map(item => ApiUtils.mapDateSchemaToStringDate(item)) as [z.ZodTypeAny, ...z.ZodTypeAny[]]; const mappedTuple = z.tuple(mappedItems); - return schema._def.rest ? mappedTuple.rest(ApiUtils.mapDateSchemaToStringDate(schema._def.rest)) : mappedTuple; + return ApiUtils.withMeta(schema, schema._def.rest ? mappedTuple.rest(ApiUtils.mapDateSchemaToStringDate(schema._def.rest)) : mappedTuple); } if (schema instanceof z.ZodRecord) { - return z.record( + return ApiUtils.withMeta(schema, z.record( schema._def.keyType as z.ZodString | z.ZodNumber | z.ZodSymbol, ApiUtils.mapDateSchemaToStringDate(schema._def.valueType) - ); + )); } if (schema instanceof z.ZodLazy) { - return z.lazy(() => ApiUtils.mapDateSchemaToStringDate(schema._def.getter())); + return ApiUtils.withMeta(schema, z.lazy(() => ApiUtils.mapDateSchemaToStringDate(schema._def.getter()))); } if (schema instanceof z.ZodPromise) { - return z.promise(ApiUtils.mapDateSchemaToStringDate(schema.unwrap())); + return ApiUtils.withMeta(schema, z.promise(ApiUtils.mapDateSchemaToStringDate(schema.unwrap()))); } return schema; } + /** Preserves `.meta()` when the date mapper has to rebuild a schema wrapper. */ + private static withMeta(source: z.ZodTypeAny, target: T): T { + const meta = z.globalRegistry.get(source); + if (!meta) { + return target; + } + return target.meta(meta) as T; + } + static toJsonSchema(schema: z.ZodType) { return z.toJSONSchema(schema, { unrepresentable: 'any', diff --git a/src/server/utils/api-response.utils.unit.spec.ts b/src/server/utils/api-response.utils.unit.spec.ts index ac387a9e..55cf06fb 100644 --- a/src/server/utils/api-response.utils.unit.spec.ts +++ b/src/server/utils/api-response.utils.unit.spec.ts @@ -42,6 +42,19 @@ describe("api-response.utils", () => { expect(responseSchema[200].parse({ deletedAt: null })).toEqual({ deletedAt: null }); expect(responseSchema[200].parse({})).toEqual({}); }); + + it("preserves secret metadata on nullable and optional fields", () => { + const responseSchema = ApiUtils.mapResponseModel(z.object({ + envVars: z.string().meta({ secret: true }), + registryPassword: z.string().nullish().meta({ secret: true }), + })); + const jsonSchema = z.toJSONSchema(responseSchema[200]) as { + properties: Record; + }; + + expect(jsonSchema.properties.envVars.secret).toBe(true); + expect(jsonSchema.properties.registryPassword.secret).toBe(true); + }); }); describe("mapError", () => { diff --git a/src/shared/model/agent-extended.model.ts b/src/shared/model/agent-extended.model.ts index ae8ce6bc..2da2be06 100644 --- a/src/shared/model/agent-extended.model.ts +++ b/src/shared/model/agent-extended.model.ts @@ -19,12 +19,20 @@ const AgentNetworkPolicyWithRulesZodModel = AgentNetworkPolicyModel.extend({ export const AgentExtendedZodModel = z.lazy(() => AgentModel.extend({ modelAlias: z.array(z.string()), project: ProjectModel, - llmGateway: LlmGatewayModel, + llmGateway: LlmGatewayModel.extend({ + encryptedAdminKey: z.string().meta({ secret: true }), + }), agentDomains: z.array(AgentDomainModel), agentVolumes: z.array(AgentVolumeModel), agentFileMounts: z.array(AgentFileMountModel), - agentGitSshKey: AgentGitSshKeyModel.nullish(), + agentGitSshKey: AgentGitSshKeyModel.extend({ + encryptedPrivateKey: z.string().meta({ secret: true }), + }).nullish(), agentNetworkPolicy: AgentNetworkPolicyWithRulesZodModel.nullish(), + containerRegistryPassword: z.string().nullish().meta({ secret: true }), + gitToken: z.string().nullish().meta({ secret: true }), + encryptedEnvVars: z.string().nullish().meta({ secret: true }), + healthCheckHttpHeadersJson: z.string().nullish().meta({ secret: true }), })); export type AgentWithProjectModel = Agent & { diff --git a/src/shared/model/agent-sandbox.model.ts b/src/shared/model/agent-sandbox.model.ts index 7cdbc645..dad46391 100644 --- a/src/shared/model/agent-sandbox.model.ts +++ b/src/shared/model/agent-sandbox.model.ts @@ -51,12 +51,12 @@ export const commandRequestZodModel = z.object({ }); export const agentSandboxAccessUrlZodModel = z.object({ - url: z.string(), + url: z.string().meta({ secret: true }), expiresAt: z.number().int().positive(), }); export const agentSandboxApiTokenZodModel = z.object({ - token: z.string().min(1), + token: z.string().min(1).meta({ secret: true }), expiresAt: z.number().int().positive(), }); diff --git a/src/shared/model/app-extended.model.ts b/src/shared/model/app-extended.model.ts index fbaefa66..d7518279 100644 --- a/src/shared/model/app-extended.model.ts +++ b/src/shared/model/app-extended.model.ts @@ -23,8 +23,16 @@ export const AppExtendedZodModel = z.lazy(() => AppModel.omit({ appNodePorts: AppNodePortModel.array(), appFileMounts: AppFileMountModel.array(), appVolumes: AppVolumeModel.array(), - appBasicAuths: AppBasicAuthModel.array(), + appBasicAuths: AppBasicAuthModel.extend({ + password: z.string().meta({ secret: true }), + }).array(), appNetworkPolicy: AppNetworkPolicyWithRulesZodModel.nullish(), + containerRegistryPassword: z.string().nullish().meta({ secret: true }), + gitToken: z.string().nullish().meta({ secret: true }), + envVars: z.string().meta({ secret: true }), + buildArgs: z.string().meta({ secret: true }), + webhookId: z.string().nullish().meta({ secret: true }), + healthCheckHttpHeadersJson: z.string().nullish().meta({ secret: true }), })) export type AppExtendedModel = z.infer; diff --git a/src/shared/model/mcp-secret-metadata.unit.spec.ts b/src/shared/model/mcp-secret-metadata.unit.spec.ts new file mode 100644 index 00000000..e1fde64e --- /dev/null +++ b/src/shared/model/mcp-secret-metadata.unit.spec.ts @@ -0,0 +1,91 @@ +import { z } from 'zod'; +import { AppExtendedZodModel } from './app-extended.model'; +import { AgentExtendedZodModel } from './agent-extended.model'; +import { agentSandboxAccessUrlZodModel, agentSandboxApiTokenZodModel } from './agent-sandbox.model'; + +type JsonSchema = Record; + +function resolve(schema: JsonSchema, root: JsonSchema): JsonSchema { + const ref = schema.$ref; + if (typeof ref !== 'string' || !ref.startsWith('#/')) { + return schema; + } + + let current: unknown = root; + for (const segment of ref.slice(2).split('/')) { + if (!current || typeof current !== 'object') { + return schema; + } + current = (current as Record)[segment.replace(/~1/g, '/').replace(/~0/g, '~')]; + } + return current && typeof current === 'object' && !Array.isArray(current) ? current as JsonSchema : schema; +} + +function secretPaths(schema: JsonSchema, root: JsonSchema = schema, path = ''): string[] { + const resolved = resolve(schema, root); + const paths: string[] = []; + + if (resolved.secret === true) { + paths.push(path || '(root)'); + } + + const properties = resolved.properties; + if (properties && typeof properties === 'object' && !Array.isArray(properties)) { + for (const [key, property] of Object.entries(properties)) { + paths.push(...secretPaths(property as JsonSchema, root, path ? `${path}.${key}` : key)); + } + } + + const items = resolved.items; + if (items && typeof items === 'object' && !Array.isArray(items)) { + paths.push(...secretPaths(items as JsonSchema, root, `${path}[]`)); + } + + for (const key of ['anyOf', 'oneOf', 'allOf']) { + const branches = resolved[key]; + if (Array.isArray(branches)) { + for (const branch of branches) { + paths.push(...secretPaths(branch as JsonSchema, root, path)); + } + } + } + + return paths; +} + +describe('MCP secret metadata', () => { + it('marks App response secrets', () => { + const paths = secretPaths(z.toJSONSchema(AppExtendedZodModel, { unrepresentable: 'any' }) as JsonSchema); + + expect(paths).toEqual(expect.arrayContaining([ + 'envVars', + 'buildArgs', + 'containerRegistryPassword', + 'gitToken', + 'appBasicAuths[].password', + 'webhookId', + 'healthCheckHttpHeadersJson', + ])); + }); + + it('marks Agent response secrets', () => { + const paths = secretPaths(z.toJSONSchema(AgentExtendedZodModel, { unrepresentable: 'any' }) as JsonSchema); + + expect(paths).toEqual(expect.arrayContaining([ + 'encryptedEnvVars', + 'containerRegistryPassword', + 'gitToken', + 'agentGitSshKey.encryptedPrivateKey', + 'llmGateway.encryptedAdminKey', + 'healthCheckHttpHeadersJson', + ])); + }); + + it('marks temporary sandbox access tokens', () => { + const accessUrlPaths = secretPaths(z.toJSONSchema(agentSandboxAccessUrlZodModel) as JsonSchema); + const apiTokenPaths = secretPaths(z.toJSONSchema(agentSandboxApiTokenZodModel) as JsonSchema); + + expect(accessUrlPaths).toContain('url'); + expect(apiTokenPaths).toContain('token'); + }); +}); diff --git a/src/shared/model/mcp-settings.model.ts b/src/shared/model/mcp-settings.model.ts new file mode 100644 index 00000000..3bc74a37 --- /dev/null +++ b/src/shared/model/mcp-settings.model.ts @@ -0,0 +1,11 @@ +import { z } from 'zod'; + +export const mcpServerModeZodModel = z.enum(['off', 'readonly', 'readwrite']); + +export type McpServerMode = z.infer; + +export const MCP_SERVER_MODE_LABELS: Record = { + off: 'Off', + readonly: 'Read only', + readwrite: 'Read & write', +}; diff --git a/src/shared/utils/mcp.utils.ts b/src/shared/utils/mcp.utils.ts new file mode 100644 index 00000000..a1cbc631 --- /dev/null +++ b/src/shared/utils/mcp.utils.ts @@ -0,0 +1,12 @@ +import { McpServerMode, mcpServerModeZodModel } from '../model/mcp-settings.model'; + +export class McpSettingsUtils { + + static isMcpServerMode(value: unknown): value is McpServerMode { + return mcpServerModeZodModel.safeParse(value).success; + } + + static isMcpServerEnabled(mode: McpServerMode): boolean { + return mode !== 'off'; + } +} From 37be0b5ca281fcab60dac4bcb77762b0b07a964b Mon Sep 17 00:00:00 2001 From: biersoeckli Date: Sun, 8 Nov 2026 09:02:20 +0000 Subject: [PATCH 4/7] refactor: MCP error handling and response utilities --- src/app/api/mcp/route.ts | 11 +- src/app/api/mcp/route.unit.spec.ts | 10 +- .../settings/developer/mcp/mcp-settings.tsx | 162 +++--------------- src/app/settings/developer/mcp/page.tsx | 7 +- src/server/api/v1/agent/deploy/route.ts | 2 +- src/server/api/v1/agent/route.ts | 10 +- src/server/api/v1/agent/sandbox/route.ts | 32 ++-- .../api/v1/agent/sandbox/route.unit.spec.ts | 10 ++ src/server/api/v1/api-index.ts | 2 +- src/server/api/v1/app/deploy/route.ts | 11 +- src/server/api/v1/app/route.ts | 10 +- src/server/api/v1/project/route.ts | 10 +- src/server/mcp/mcp-errors.ts | 32 ---- src/server/mcp/mcp-handler.ts | 13 +- src/server/mcp/mcp-server.factory.ts | 7 +- src/server/utils/mcp-response.utils.ts | 44 +++++ src/shared/utils/pagination.utils.ts | 12 +- .../utils/pagination.utils.unit.spec.ts | 19 +- src/shared/utils/settings-navigation.ts | 2 +- 19 files changed, 138 insertions(+), 268 deletions(-) delete mode 100644 src/server/mcp/mcp-errors.ts create mode 100644 src/server/utils/mcp-response.utils.ts diff --git a/src/app/api/mcp/route.ts b/src/app/api/mcp/route.ts index 4959dd67..7c5a47fe 100644 --- a/src/app/api/mcp/route.ts +++ b/src/app/api/mcp/route.ts @@ -1,8 +1,8 @@ import { v1Api } from '@/server/api/v1/api-index'; import { resolveQuickStackAuthInfo } from '@/server/mcp/mcp-auth'; -import { jsonRpcErrorResponse, MCP_ERROR_CODES } from '@/server/mcp/mcp-errors'; import { createQuickStackMcpHandler } from '@/server/mcp/mcp-handler'; -import paramService, { ParamService } from '@/server/services/param.service'; +import paramService from '@/server/services/param.service'; +import { MCP_ERROR_CODES, McpResponseUtils } from '@/server/utils/mcp-response.utils'; export const dynamic = 'force-dynamic'; @@ -12,14 +12,13 @@ const mcpHandler = createQuickStackMcpHandler({ }); async function handleRequest(request: Request): Promise { - const [canaryEnabled, mode, sendSecrets] = await Promise.all([ - paramService.getBoolean(ParamService.USE_CANARY_CHANNEL), + const [mode, sendSecrets] = await Promise.all([ paramService.getMcpServerMode(), paramService.getMcpSendSecrets(), ]); - if (!canaryEnabled || mode === 'off') { - return jsonRpcErrorResponse({ + if (mode === 'off') { + return McpResponseUtils.jsonRpcErrorResponse({ code: MCP_ERROR_CODES.SERVER_DISABLED, message: 'MCP server is disabled.', status: 404, diff --git a/src/app/api/mcp/route.unit.spec.ts b/src/app/api/mcp/route.unit.spec.ts index 24a9f7dc..61dd6bc5 100644 --- a/src/app/api/mcp/route.unit.spec.ts +++ b/src/app/api/mcp/route.unit.spec.ts @@ -57,13 +57,17 @@ describe('MCP route enablement', () => { expect(routeMocks.mcpFetch).not.toHaveBeenCalled(); }); - it('rejects requests when the canary channel is disabled', async () => { + it('handles requests when the canary channel is disabled', async () => { setParams({ canary: false, mode: 'readwrite' }); + routeMocks.mcpFetch.mockResolvedValue(new Response('handled', { status: 200 })); const response = await POST(new Request('http://localhost/api/mcp', { method: 'POST' })); - expect(response.status).toBe(404); - expect(routeMocks.mcpFetch).not.toHaveBeenCalled(); + expect(response.status).toBe(200); + expect(routeMocks.mcpFetch).toHaveBeenCalledWith(expect.any(Request), { + mode: 'readwrite', + sendSecrets: false, + }); }); it('passes the read-only mode to the MCP handler', async () => { diff --git a/src/app/settings/developer/mcp/mcp-settings.tsx b/src/app/settings/developer/mcp/mcp-settings.tsx index 3942f8fa..f1cf75ee 100644 --- a/src/app/settings/developer/mcp/mcp-settings.tsx +++ b/src/app/settings/developer/mcp/mcp-settings.tsx @@ -1,20 +1,21 @@ 'use client'; -import { useEffect, useState, type ReactNode } from 'react'; +import { useEffect, useState } from 'react'; import { toast } from 'sonner'; -import { Copy, ShieldAlert } from 'lucide-react'; +import { CircleHelp, Copy, Key, ShieldAlert } from 'lucide-react'; import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; import { Button } from '@/components/ui/button'; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'; import { Label } from '@/components/ui/label'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { Switch } from '@/components/ui/switch'; -import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; import LoadingSpinner from '@/components/ui/loading-spinner'; import { Toast } from '@/frontend/utils/toast.utils'; import { MCP_SERVER_MODE_LABELS, McpServerMode, mcpServerModeZodModel } from '@/shared/model/mcp-settings.model'; import { McpSettingsUtils } from '@/shared/utils/mcp.utils'; import { setMcpSendSecrets, setMcpServerMode } from '../../actions'; +import Link from 'next/link'; function Snippet({ value }: { value: string }) { return ( @@ -38,15 +39,6 @@ function Snippet({ value }: { value: string }) { ); } -function Step({ title, children }: { title: string; children: ReactNode }) { - return ( -
-

{title}

- {children} -
- ); -} - export default function McpSettings({ mcpMode, sendSecrets }: { mcpMode: McpServerMode; sendSecrets: boolean }) { const [mode, setMode] = useState(mcpMode); const [sendSecretsViaMcp, setSendSecretsViaMcp] = useState(sendSecrets); @@ -101,62 +93,14 @@ export default function McpSettings({ mcpMode, sendSecrets }: { mcpMode: McpServ } }; - const claudeJson = `{ - "mcpServers": { - "quickstack": { - "type": "http", - "url": "${mcpUrl}", - "headers": { "Authorization": "Bearer " } - } - } -}`; - - const claudeCli = `claude mcp add --transport http quickstack ${mcpUrl} \\ - --header "Authorization: Bearer "`; - - const codexToml = `[mcp_servers.quickstack] -url = "${mcpUrl}" -bearer_token_env_var = "QUICKSTACK_API_KEY"`; - - const codexStaticToml = `[mcp_servers.quickstack] -url = "${mcpUrl}" -http_headers = { Authorization = "Bearer " }`; - - const copilotJson = `{ - "servers": { - "quickstack": { - "type": "http", - "url": "${mcpUrl}", - "headers": { "Authorization": "Bearer \${input:quickstack-api-key}" } - } - } -}`; - - const genericJson = `POST ${mcpUrl} -Content-Type: application/json -Accept: application/json, text/event-stream -Authorization: Bearer - -{"jsonrpc":"2.0","id":1,"method":"tools/list"}`; - return (
- - - Canary feature: secrets can flow through MCP - - The MCP server is a canary feature and still in active development. Tool arguments and results are passed to the AI host and model. - Read responses redact secret fields unless Send secrets is enabled. Write tool arguments are still sent to QuickStack, - so never put secrets into a prompt that should not reach the AI host. Only connect trusted clients and use a REST API Key - from a user with the minimum required permissions. - - MCP Server - Canary + Beta Expose QuickStack as a remote Model Context Protocol server. AI clients such as Claude Code, @@ -166,10 +110,10 @@ Authorization: Bearer
- +