From e11fe87bcdca44371725be2665f938eb2739b694 Mon Sep 17 00:00:00 2001 From: aperolschpritz Date: Sat, 3 Oct 2026 10:42:43 +0000 Subject: [PATCH 1/9] feat: add remote MCP server backed by the REST API --- package.json | 1 + .../mcp/mcp-handler.integration.spec.ts | 117 +++++++++ src/app/api/mcp/route.ts | 14 + src/server/mcp/mcp-auth.ts | 20 ++ src/server/mcp/mcp-handler.ts | 75 ++++++ src/server/mcp/mcp-handler.unit.spec.ts | 245 ++++++++++++++++++ src/server/mcp/mcp-server.factory.ts | 193 ++++++++++++++ src/server/mcp/operation-executor.ts | 73 ++++++ src/server/mcp/operation-registry.ts | 101 ++++++++ yarn.lock | 17 +- 10 files changed, 855 insertions(+), 1 deletion(-) create mode 100644 src/__tests__/integration/server/mcp/mcp-handler.integration.spec.ts create mode 100644 src/app/api/mcp/route.ts create mode 100644 src/server/mcp/mcp-auth.ts create mode 100644 src/server/mcp/mcp-handler.ts create mode 100644 src/server/mcp/mcp-handler.unit.spec.ts create mode 100644 src/server/mcp/mcp-server.factory.ts create mode 100644 src/server/mcp/operation-executor.ts create mode 100644 src/server/mcp/operation-registry.ts diff --git a/package.json b/package.json index d5efc62f..a983d680 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "@elysiajs/openapi": "^1.4.15", "@hookform/resolvers": "5.5.7", "@kubernetes/client-node": "^1.4.0", + "@modelcontextprotocol/server": "^2.3.0", "@next-auth/prisma-adapter": "^1.0.7", "@prisma/adapter-better-sqlite3": "^7.1.0", "@prisma/client": "7.1.0", diff --git a/src/__tests__/integration/server/mcp/mcp-handler.integration.spec.ts b/src/__tests__/integration/server/mcp/mcp-handler.integration.spec.ts new file mode 100644 index 00000000..e5937c45 --- /dev/null +++ b/src/__tests__/integration/server/mcp/mcp-handler.integration.spec.ts @@ -0,0 +1,117 @@ +// @vitest-environment node + +import mockNextJsCaching from '@/__tests__/nextjs-cache.utils'; +mockNextJsCaching(); + +vi.mock('@/server/adapter/kubernetes-api.adapter', () => ({ default: {} })); + +import { createPrismaTestContext } from '@/__tests__/prisma-test.utils'; +import { mockPathUtilsForTests } from '@/__tests__/path-test.utils'; +import { v1Api } from '@/server/api/v1/api-index'; +import { resolveQuickStackAuthInfo } from '@/server/mcp/mcp-auth'; +import { createQuickStackMcpHandler } from '@/server/mcp/mcp-handler'; +import restApiKeyService from '@/server/services/rest-api-key.service'; +import userGroupService from '@/server/services/user-group.service'; +import userService from '@/server/services/user.service'; +import { PathUtils } from '@/server/utils/path.utils'; + +function jsonRpcRequest(options: { + method: string; + params?: Record; + apiKey: string; + toolName?: string; +}) { + const { method, params, apiKey, toolName } = options; + const headers: Record = { + 'content-type': 'application/json', + accept: 'application/json, text/event-stream', + 'mcp-protocol-version': '2026-07-28', + 'mcp-method': method, + authorization: `Bearer ${apiKey}`, + }; + if (toolName) { + headers['mcp-name'] = toolName; + } + return new Request('http://quickstack.test/api/mcp', { + method: 'POST', + headers, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method, + params: { + ...(params ?? {}), + _meta: { + 'io.modelcontextprotocol/protocolVersion': '2026-07-28', + 'io.modelcontextprotocol/clientCapabilities': {}, + 'io.modelcontextprotocol/clientInfo': { name: 'integration', version: '1.0.0' }, + }, + }, + }), + }); +} + +describe('QuickStack MCP integration', () => { + createPrismaTestContext('mcp-handler'); + const { originalInternalDataRoot, originalTempDataRoot } = mockPathUtilsForTests(); + + beforeEach(() => { + process.env.NEXTAUTH_SECRET = process.env.NEXTAUTH_SECRET ?? 'test-nextauth-secret'; + }); + + afterAll(() => { + if (originalInternalDataRoot) { + Object.defineProperty(PathUtils, 'internalDataRoot', originalInternalDataRoot); + } + if (originalTempDataRoot) { + Object.defineProperty(PathUtils, 'tempDataRoot', originalTempDataRoot); + } + vi.restoreAllMocks(); + }); + + async function createAdminApiKey() { + const adminRole = await userGroupService.getOrCreateAdminRole(); + const user = await userService.registerUser('admin-mcp-test@example.com', 'test-password', adminRole.id); + return restApiKeyService.create(user.id, 'mcp-integration-test'); + } + + it('derives operations from the real REST API and hides binary routes', async () => { + const apiKey = await createAdminApiKey(); + const handler = createQuickStackMcpHandler({ app: v1Api, resolveAuthInfo: resolveQuickStackAuthInfo }); + + const response = await handler.fetch(jsonRpcRequest({ + method: 'tools/call', + toolName: 'search_operations', + apiKey, + params: { name: 'search_operations', arguments: { detail: 'full' } }, + })); + const body = await response.json(); + const operations = body.result.structuredContent.operations as Array<{ operationId: string; supported: boolean }>; + const byId = new Map(operations.map((operation) => [operation.operationId, operation])); + + expect(response.status).toBe(200); + expect(operations.length).toBeGreaterThan(15); + expect(byId.has('listProjects')).toBe(true); + expect(byId.has('listApps')).toBe(true); + expect(byId.has('saveAgent')).toBe(true); + expect(byId.get('readAgentSandboxFile')?.supported).toBe(false); + expect(byId.get('writeAgentSandboxFile')?.supported).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 }); + + const response = await handler.fetch(jsonRpcRequest({ + method: 'tools/call', + toolName: 'execute_operation', + apiKey, + params: { name: 'execute_operation', arguments: { operationId: 'listProjects' } }, + })); + const body = await response.json(); + + expect(response.status).toBe(200); + expect(body.result.isError).not.toBe(true); + expect(body.result.structuredContent.result).toEqual([]); + }, 120_000); +}); diff --git a/src/app/api/mcp/route.ts b/src/app/api/mcp/route.ts new file mode 100644 index 00000000..f2bef92e --- /dev/null +++ b/src/app/api/mcp/route.ts @@ -0,0 +1,14 @@ +import { v1Api } from '@/server/api/v1/api-index'; +import { resolveQuickStackAuthInfo } from '@/server/mcp/mcp-auth'; +import { createQuickStackMcpHandler } from '@/server/mcp/mcp-handler'; + +export const dynamic = 'force-dynamic'; + +const mcpHandler = createQuickStackMcpHandler({ + app: v1Api, + resolveAuthInfo: resolveQuickStackAuthInfo, +}); + +export const POST = mcpHandler.fetch; +export const GET = mcpHandler.fetch; +export const DELETE = mcpHandler.fetch; diff --git a/src/server/mcp/mcp-auth.ts b/src/server/mcp/mcp-auth.ts new file mode 100644 index 00000000..c909c779 --- /dev/null +++ b/src/server/mcp/mcp-auth.ts @@ -0,0 +1,20 @@ +import type { AuthInfo } from '@modelcontextprotocol/server'; +import { getIdentityFromApiKeyHeader } from '../utils/requester-identity.utils'; + +export type McpAuthResolver = (request: Request) => Promise; + +export const resolveQuickStackAuthInfo: McpAuthResolver = async (request) => { + const authorization = request.headers.get('authorization'); + const identity = await getIdentityFromApiKeyHeader(authorization); + if (!identity || !authorization) { + return null; + } + + const token = authorization.slice('Bearer '.length).trim(); + return { + token, + clientId: identity.session.userId, + scopes: [], + extra: { identity }, + }; +}; diff --git a/src/server/mcp/mcp-handler.ts b/src/server/mcp/mcp-handler.ts new file mode 100644 index 00000000..5b239849 --- /dev/null +++ b/src/server/mcp/mcp-handler.ts @@ -0,0 +1,75 @@ +import { + createMcpHandler, + hostHeaderValidationResponse, + originValidationResponse, +} from '@modelcontextprotocol/server'; +import type { AuthInfo } from '@modelcontextprotocol/server'; +import type { McpAuthResolver } from './mcp-auth'; +import { buildQuickStackMcpServer } from './mcp-server.factory'; +import { buildOperationRegistry } from './operation-registry'; +import type { McpSourceApp, OperationDescriptor } from './operation-registry'; + +export type QuickStackMcpHandlerOptions = { + app: McpSourceApp; + resolveAuthInfo: McpAuthResolver; + allowedOrigins?: string[]; + allowedHosts?: string[]; +}; + +export type QuickStackMcpHandler = { + fetch: (request: Request) => Promise; + close: () => Promise; + operations: OperationDescriptor[]; +}; + +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', + }, + } + ); +} + +export function createQuickStackMcpHandler(options: QuickStackMcpHandlerOptions): QuickStackMcpHandler { + const { app, resolveAuthInfo, allowedOrigins = [], allowedHosts } = options; + const operations = buildOperationRegistry(app); + + const handler = createMcpHandler( + ({ authInfo }) => buildQuickStackMcpServer({ app, operations, authInfo }), + { responseMode: 'json' } + ); + + const fetch = async (request: Request): Promise => { + if (allowedHosts && allowedHosts.length > 0) { + const hostRejected = hostHeaderValidationResponse(request, allowedHosts); + if (hostRejected) { + return hostRejected; + } + } + + const originRejected = originValidationResponse(request, allowedOrigins); + if (originRejected) { + return originRejected; + } + + let authInfo: AuthInfo | null = null; + try { + authInfo = await resolveAuthInfo(request); + } catch { + authInfo = null; + } + + if (!authInfo) { + return unauthorizedResponse(); + } + + return handler.fetch(request, { authInfo }); + }; + + 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 new file mode 100644 index 00000000..15aad6fa --- /dev/null +++ b/src/server/mcp/mcp-handler.unit.spec.ts @@ -0,0 +1,245 @@ +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'; + +const VALID_TOKEN = 'valid-token'; + +function unauthorized() { + return new Response(JSON.stringify({ title: 'Unauthorized' }), { status: 401 }); +} + +function buildFixtureApp() { + const unauthorizedSchema = z.object({ title: z.string() }); + + return new Elysia({ prefix: '/api/v1' }) + .derive(({ request }) => ({ authenticated: request.headers.get('authorization') === `Bearer ${VALID_TOKEN}` })) + .get('/projects', ({ authenticated }) => { + if (!authenticated) return unauthorized(); + return [{ id: 'p1', name: 'Demo' }]; + }, { + response: { 200: z.array(z.object({ id: z.string(), name: z.string() })), 401: unauthorizedSchema }, + detail: { operationId: 'listProjects', summary: 'List projects', tags: ['Projects'] }, + }) + .get('/projects/:id', ({ params, query, authenticated }) => { + if (!authenticated) return unauthorized(); + return { id: params.id, search: query.search ?? null }; + }, { + params: z.object({ id: z.string() }), + query: z.object({ search: z.string().optional() }), + response: { 200: z.object({ id: z.string(), search: z.string().nullable() }), 401: unauthorizedSchema }, + detail: { operationId: 'getProject', summary: 'Get project', tags: ['Projects'] }, + }) + .post('/projects', ({ body, authenticated }) => { + if (!authenticated) return unauthorized(); + return { id: 'p2', name: body.name }; + }, { + body: z.object({ name: z.string() }), + response: { 200: z.object({ id: z.string(), name: z.string() }), 401: unauthorizedSchema }, + detail: { operationId: 'saveProject', summary: 'Save project', tags: ['Projects'] }, + }) + .post('/upload', () => new Response(JSON.stringify({ ok: true }), { headers: { 'content-type': 'application/json' } }), { + parse: 'none', + response: { 200: z.object({ ok: z.boolean() }) }, + detail: { operationId: 'uploadFile', summary: 'Upload a file', tags: ['Files'] }, + }) + .get('/logs/stream', () => new Response('stream'), { + detail: { operationId: 'streamLogs', summary: 'Stream logs', tags: ['Logs'] }, + }); +} + +function buildHandler(overrides?: { resolveAuthInfo?: (request: Request) => Promise }): QuickStackMcpHandler { + const app = buildFixtureApp(); + return createQuickStackMcpHandler({ + app, + resolveAuthInfo: overrides?.resolveAuthInfo ?? (async (request) => { + if (request.headers.get('authorization') !== `Bearer ${VALID_TOKEN}`) { + return null; + } + return { token: VALID_TOKEN, clientId: 'user-1', scopes: [] }; + }), + }); +} + +function jsonRpcRequest(options: { + method: string; + params?: Record; + token?: string | null; + toolName?: string; + origin?: string; +}) { + const { method, params, token = VALID_TOKEN, toolName, origin } = options; + const headers: Record = { + 'content-type': 'application/json', + accept: 'application/json, text/event-stream', + 'mcp-protocol-version': '2026-07-28', + 'mcp-method': method, + }; + if (toolName) { + headers['mcp-name'] = toolName; + } + if (token) { + headers.authorization = `Bearer ${token}`; + } + if (origin) { + headers.origin = origin; + } + return new Request('http://localhost/api/mcp', { + method: 'POST', + headers, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method, + params: { + ...(params ?? {}), + _meta: { + 'io.modelcontextprotocol/protocolVersion': '2026-07-28', + 'io.modelcontextprotocol/clientCapabilities': {}, + 'io.modelcontextprotocol/clientInfo': { name: 'test', version: '1.0.0' }, + }, + }, + }), + }); +} + +async function listTools(handler: QuickStackMcpHandler) { + const response = await handler.fetch(jsonRpcRequest({ method: 'tools/list' })); + return { status: response.status, body: await response.json() }; +} + +async function callTool(handler: QuickStackMcpHandler, name: string, args: Record) { + const response = await handler.fetch(jsonRpcRequest({ + method: 'tools/call', + toolName: name, + params: { name, arguments: args }, + })); + return { status: response.status, body: await response.json() }; +} + +describe('QuickStack MCP handler', () => { + it('lists exactly the two meta tools', async () => { + const { status, body } = await listTools(buildHandler()); + + expect(status).toBe(200); + const names = body.result.tools.map((tool: { name: string }) => tool.name).sort(); + expect(names).toEqual(['execute_operation', 'search_operations']); + }); + + 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); + }); + + it('rejects a request with no credential', async () => { + const response = await buildHandler().fetch(jsonRpcRequest({ method: 'tools/list', token: null })); + expect(response.status).toBe(401); + }); + + it('rejects a cross-origin browser request', async () => { + const response = await buildHandler().fetch(jsonRpcRequest({ method: 'tools/list', origin: 'https://evil.example' })); + expect(response.status).toBe(403); + }); + + it('derives operations from the routes in deterministic id order', async () => { + const { body } = await callTool(buildHandler(), 'search_operations', {}); + const operations = body.result.structuredContent.operations as Array<{ operationId: string }>; + const ids = operations.map((operation) => operation.operationId); + + expect(ids).toEqual([...ids].sort()); + expect(ids).toContain('listProjects'); + expect(ids).toContain('getProject'); + }); + + it('filters operations by search term', async () => { + const { body } = await callTool(buildHandler(), '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 summaryOperation = summary.body.result.structuredContent.operations[0]; + const fullOperation = full.body.result.structuredContent.operations[0]; + + expect(summaryOperation.bodySchema).toBeUndefined(); + expect(fullOperation.pathParamsSchema).toBeDefined(); + expect(fullOperation.querySchema).toBeDefined(); + }); + + it('marks streaming and manually parsed operations as unsupported', async () => { + const { body } = await callTool(buildHandler(), 'search_operations', { detail: 'full' }); + const operations = body.result.structuredContent.operations as Array<{ operationId: string; supported: boolean }>; + + const streamLogs = operations.find((operation) => operation.operationId === 'streamLogs'); + const uploadFile = operations.find((operation) => operation.operationId === 'uploadFile'); + const listProjects = operations.find((operation) => operation.operationId === 'listProjects'); + + expect(streamLogs?.supported).toBe(false); + expect(uploadFile?.supported).toBe(false); + expect(listProjects?.supported).toBe(true); + }); + + it('executes a read operation and returns the REST payload', async () => { + const { body } = await callTool(buildHandler(), 'execute_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_operation', { + operationId: 'getProject', + pathParams: { id: 'p-42' }, + query: { search: 'needle' }, + }); + + expect(body.result.structuredContent.result).toEqual({ id: 'p-42', search: 'needle' }); + }); + + it('sends the request body for write operations', async () => { + const { body } = await callTool(buildHandler(), 'execute_operation', { + operationId: 'saveProject', + body: { name: 'New project' }, + }); + + expect(body.result.structuredContent.result).toEqual({ id: 'p2', name: 'New project' }); + }); + + it('forwards the bearer credential to the REST route', async () => { + const { body } = await callTool(buildHandler(), 'execute_operation', { operationId: 'listProjects' }); + + expect(body.result.isError).not.toBe(true); + expect(body.result.structuredContent.result).toHaveLength(1); + }); + + it('returns a tool error for an unknown operation id', async () => { + const { body } = await callTool(buildHandler(), '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_operation', { operationId: 'streamLogs' }); + + expect(body.result.isError).toBe(true); + }); + + it('returns a tool error carrying the REST problem detail on failure', async () => { + const app = buildFixtureApp(); + const handler = createQuickStackMcpHandler({ + app, + resolveAuthInfo: async () => ({ token: 'wrong', clientId: 'user-1', scopes: [] }), + }); + + const { body } = await callTool(handler, 'execute_operation', { operationId: 'listProjects' }); + + expect(body.result.isError).toBe(true); + expect(body.result.content[0].text).toContain('401'); + }); +}); diff --git a/src/server/mcp/mcp-server.factory.ts b/src/server/mcp/mcp-server.factory.ts new file mode 100644 index 00000000..7e4bbfa6 --- /dev/null +++ b/src/server/mcp/mcp-server.factory.ts @@ -0,0 +1,193 @@ +import { McpServer } from '@modelcontextprotocol/server'; +import type { AuthInfo } from '@modelcontextprotocol/server'; +import { z } from 'zod'; +import { executeOperation } from './operation-executor'; +import type { McpSourceApp, OperationDescriptor } from './operation-registry'; + +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.'), +}); + +const operationSummarySchema = z.object({ + operationId: z.string(), + method: z.string(), + path: z.string(), + summary: z.string().optional(), + description: z.string().optional(), + tags: z.array(z.string()), + supported: z.boolean(), +}); + +const operationFullSchema = operationSummarySchema.extend({ + pathParamsSchema: z.unknown().optional(), + querySchema: z.unknown().optional(), + bodySchema: z.unknown().optional(), + responseSchema: z.unknown().optional(), + unsupportedReason: z.string().optional(), +}); + +const searchOutputSchema = z.object({ + operations: z.array(operationFullSchema), +}); + +const executeInputSchema = z.object({ + operationId: z.string().describe('Operation id returned by search_operations.'), + 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.'), + body: z.unknown().optional().describe('JSON request body for write operations.'), +}); + +const executeOutputSchema = z.object({ + status: z.number().int(), + result: z.unknown(), +}); + +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,', + 'then call execute_operation. Operations mirror the QuickStack REST API.', + 'Use detail "full" only when you need the exact input and output schema.', +].join(' '); + +const EXECUTE_TOOL_DESCRIPTION = [ + 'Execute a QuickStack operation by its operation id.', + 'Call search_operations first to find a valid operation id and its schema.', + 'Map your arguments to pathParams, query and body according to the operation schema:', + 'pathParams fills :placeholders in the path, query becomes the query string, and', + 'body is the JSON request body for write operations.', + 'Responses follow the QuickStack REST API. Errors are returned as tool errors.', +].join(' '); + +function serializeOperation(operation: OperationDescriptor, detail: 'name' | 'summary' | 'full') { + const summary = { + operationId: operation.operationId, + method: operation.method, + path: operation.path, + tags: operation.tags, + supported: operation.supported, + ...(detail === 'name' ? {} : { + summary: operation.summary, + description: operation.description, + }), + }; + + if (detail !== 'full') { + return summary; + } + + return { + ...summary, + pathParamsSchema: operation.pathParamsSchema, + querySchema: operation.querySchema, + bodySchema: operation.bodySchema, + responseSchema: operation.responseSchema, + unsupportedReason: operation.unsupportedReason, + }; +} + +function matchesSearch(operation: OperationDescriptor, term: string): boolean { + if (!term) { + return true; + } + const haystack = [ + operation.operationId, + operation.summary, + operation.description, + ...operation.tags, + ].filter((value): value is string => !!value); + return haystack.some((value) => value.toLowerCase().includes(term)); +} + +function errorResult(text: string) { + return { isError: true as const, content: [{ type: 'text' as const, text }] }; +} + +function 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}`; + } + } + return `QuickStack returned HTTP ${status}.`; +} + +export function buildQuickStackMcpServer(options: { + app: McpSourceApp; + operations: OperationDescriptor[]; + authInfo?: AuthInfo; +}): McpServer { + const { app, operations, authInfo } = options; + const server = new McpServer({ name: 'quickstack', version: '1.0.0' }); + + server.registerTool( + 'search_operations', + { + title: 'Search QuickStack operations', + description: SEARCH_TOOL_DESCRIPTION, + inputSchema: searchInputSchema, + outputSchema: searchOutputSchema, + annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: false }, + }, + async ({ search, detail }) => { + const term = search?.trim().toLowerCase() ?? ''; + const operationList = operations + .filter((operation) => matchesSearch(operation, term)) + .map((operation) => serializeOperation(operation, detail)); + + return { + content: [{ type: 'text' as const, text: JSON.stringify({ operations: operationList }) }], + structuredContent: { operations: operationList }, + }; + } + ); + + server.registerTool( + 'execute_operation', + { + title: 'Execute QuickStack operation', + description: EXECUTE_TOOL_DESCRIPTION, + inputSchema: executeInputSchema, + outputSchema: executeOutputSchema, + annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false }, + }, + async ({ operationId, pathParams, query, body }) => { + const operation = operations.find((candidate) => candidate.operationId === operationId); + if (!operation) { + return errorResult(`Unknown operation "${operationId}". Call search_operations to find valid operation ids.`); + } + if (!operation.supported) { + return errorResult(`Operation "${operationId}" is not available over MCP. ${operation.unsupportedReason ?? ''}`.trim()); + } + + let execution; + try { + execution = await executeOperation({ + app, + operation, + pathParams: pathParams ?? {}, + query: query ?? {}, + body, + bearerToken: authInfo?.token, + }); + } catch (error) { + return errorResult(error instanceof Error ? error.message : 'Operation failed.'); + } + + if (execution.status >= 200 && execution.status < 300) { + const result = execution.result ?? null; + return { + content: [{ type: 'text' as const, text: JSON.stringify(result) }], + structuredContent: { status: execution.status, result }, + }; + } + + return errorResult(describeFailure(execution.status, execution.result)); + } + ); + + return server; +} diff --git a/src/server/mcp/operation-executor.ts b/src/server/mcp/operation-executor.ts new file mode 100644 index 00000000..f3b538a4 --- /dev/null +++ b/src/server/mcp/operation-executor.ts @@ -0,0 +1,73 @@ +import type { McpSourceApp, OperationDescriptor } from './operation-registry'; + +export type OperationExecutionResult = { + status: number; + result: unknown; +}; + +const PATH_PARAM_PATTERN = /:([A-Za-z0-9_]+)/g; + +export function resolveOperationPath(template: string, pathParams: Record): string { + return template.replace(PATH_PARAM_PATTERN, (_match, name: string) => { + const value = pathParams[name]; + if (value === undefined || value === null || value === '') { + throw new Error(`Missing required path parameter "${name}".`); + } + return encodeURIComponent(String(value)); + }); +} + +export async function executeOperation(options: { + app: McpSourceApp; + operation: OperationDescriptor; + pathParams: Record; + query: Record; + body: unknown; + bearerToken?: string; +}): Promise { + const { app, operation, pathParams, query, body, bearerToken } = options; + const path = resolveOperationPath(operation.path, pathParams); + const url = new URL(path, 'http://quickstack.local'); + + for (const [key, value] of Object.entries(query ?? {})) { + if (value === undefined || value === null) { + continue; + } + if (Array.isArray(value)) { + for (const item of value) { + url.searchParams.append(key, String(item)); + } + } else { + url.searchParams.set(key, String(value)); + } + } + + const headers = new Headers(); + if (bearerToken) { + headers.set('authorization', `Bearer ${bearerToken}`); + } + + let requestBody: string | undefined; + if (body !== undefined && operation.method !== 'GET' && operation.method !== 'DELETE') { + headers.set('content-type', 'application/json'); + requestBody = JSON.stringify(body); + } + + const response = await app.handle(new Request(url, { + method: operation.method, + headers, + body: requestBody, + })); + + const text = await response.text(); + let result: unknown = undefined; + if (text) { + try { + result = JSON.parse(text); + } catch { + result = text; + } + } + + return { status: response.status, result }; +} diff --git a/src/server/mcp/operation-registry.ts b/src/server/mcp/operation-registry.ts new file mode 100644 index 00000000..cf302750 --- /dev/null +++ b/src/server/mcp/operation-registry.ts @@ -0,0 +1,101 @@ +import { z } from 'zod'; + +export type McpSourceApp = { + routes: unknown[]; + handle: (request: Request) => Promise; +}; + +export type OperationDescriptor = { + operationId: string; + method: string; + path: string; + summary?: string; + description?: string; + tags: string[]; + pathParamsSchema?: Record; + querySchema?: Record; + bodySchema?: Record; + responseSchema?: Record; + supported: boolean; + unsupportedReason?: string; +}; + +type ElysiaRoute = { + method: string; + path: string; + hooks?: { + params?: unknown; + query?: unknown; + body?: unknown; + response?: Record; + parse?: Array<{ fn?: string }>; + detail?: { + operationId?: string; + summary?: string; + description?: string; + tags?: string[]; + }; + }; +}; + +function toJsonSchema(schema: unknown): Record | undefined { + if (!schema || typeof schema !== 'object' || !('~standard' in schema)) { + return undefined; + } + + try { + return toJsonSchemaUnsafe(schema); + } catch { + return undefined; + } +} + +function toJsonSchemaUnsafe(schema: unknown): Record { + return z.toJSONSchema(schema as z.ZodType, { unrepresentable: 'any' }) as Record; +} + +export function buildOperationRegistry(app: McpSourceApp): OperationDescriptor[] { + const routes = (app.routes ?? []) as ElysiaRoute[]; + const seen = new Set(); + const descriptors: OperationDescriptor[] = []; + + for (const route of routes) { + const detail = route.hooks?.detail; + const operationId = detail?.operationId; + if (!operationId || seen.has(operationId)) { + continue; + } + seen.add(operationId); + + const responseSchema = route.hooks?.response?.['200']; + const manualBodyParsing = (route.hooks?.parse ?? []).some((entry) => entry?.fn === 'none'); + + let supported = true; + let unsupportedReason: string | undefined; + + if (!responseSchema) { + supported = false; + unsupportedReason = 'Operation does not declare a JSON response.'; + } else if (manualBodyParsing) { + supported = false; + unsupportedReason = 'Operation consumes a non-JSON request body.'; + } + + descriptors.push({ + operationId, + method: route.method, + path: route.path, + summary: detail?.summary, + description: detail?.description, + tags: detail?.tags ?? [], + pathParamsSchema: toJsonSchema(route.hooks?.params), + querySchema: toJsonSchema(route.hooks?.query), + bodySchema: toJsonSchema(route.hooks?.body), + responseSchema: toJsonSchema(responseSchema), + supported, + unsupportedReason, + }); + } + + return descriptors.sort((left, right) => left.operationId.localeCompare(right.operationId)); +} diff --git a/yarn.lock b/yarn.lock index c6919736..7f688687 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1583,6 +1583,13 @@ semver "^7.3.5" tar "^6.1.11" +"@modelcontextprotocol/core@2.3.0": + version "2.3.0" + resolved "https://registry.yarnpkg.com/@modelcontextprotocol/core/-/core-2.3.0.tgz#e16da853964147db06c34e12fab7269a07e10ed6" + integrity sha512-09BHFaNVBe5pB2AhzCfLj0cqmsKLERvZQ4FblzP90UMGLaXVqdZPByFjURwsUYCmxonUW2yAwgTTvle+8O2/8w== + dependencies: + zod "^4.2.0" + "@modelcontextprotocol/sdk@^1.26.0": version "1.30.0" resolved "https://registry.yarnpkg.com/@modelcontextprotocol/sdk/-/sdk-1.30.0.tgz#dfa8a48347ec2d2c0d47917d7dc57f754e37f5ff" @@ -1606,6 +1613,14 @@ zod "^3.25 || ^4.0" zod-to-json-schema "^3.25.1" +"@modelcontextprotocol/server@^2.3.0": + version "2.3.0" + resolved "https://registry.yarnpkg.com/@modelcontextprotocol/server/-/server-2.3.0.tgz#02a721e2614482af6fc94b6ee0083cff13cc18f5" + integrity sha512-+6b0LdsQLmHsvS7J6sQJ4weTpzUn61fU1JKS+321/+IyDYoHW5CR2Tgqcs6GgAAQvlesCo6yX3xttgmx+XGtJw== + dependencies: + "@modelcontextprotocol/core" "2.3.0" + zod "^4.2.0" + "@mrleebo/prisma-ast@0.12.1": version "0.12.1" resolved "https://registry.yarnpkg.com/@mrleebo/prisma-ast/-/prisma-ast-0.12.1.tgz#cb1cac178d98e351b6f300981abc1ffeb28f7cdd" @@ -10685,7 +10700,7 @@ zod@^3.24.1: resolved "https://registry.yarnpkg.com/zod/-/zod-3.25.76.tgz#26841c3f6fd22a6a2760e7ccb719179768471e34" integrity sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ== -"zod@^3.25 || ^4.0": +"zod@^3.25 || ^4.0", zod@^4.2.0: version "4.6.5" resolved "https://registry.yarnpkg.com/zod/-/zod-4.6.5.tgz#e3af3edbdc260239c52f73caad05dbbffe6e82cd" integrity sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q== From f27bf50400e4cd1ed5501f0e719145fd349ebfe0 Mon Sep 17 00:00:00 2001 From: aperolschpritz Date: Sat, 3 Oct 2026 10:44:32 +0000 Subject: [PATCH 2/9] fix: address MCP server review findings --- src/server/mcp/mcp-handler.ts | 3 ++- src/server/mcp/mcp-handler.unit.spec.ts | 5 +++++ src/server/mcp/operation-executor.ts | 5 +++-- src/server/mcp/operation-registry.ts | 2 +- 4 files changed, 11 insertions(+), 4 deletions(-) diff --git a/src/server/mcp/mcp-handler.ts b/src/server/mcp/mcp-handler.ts index 5b239849..4fbef974 100644 --- a/src/server/mcp/mcp-handler.ts +++ b/src/server/mcp/mcp-handler.ts @@ -52,7 +52,8 @@ export function createQuickStackMcpHandler(options: QuickStackMcpHandlerOptions) } } - const originRejected = originValidationResponse(request, allowedOrigins); + const allowedOriginHostnames = [...allowedOrigins, new URL(request.url).hostname]; + const originRejected = originValidationResponse(request, allowedOriginHostnames); if (originRejected) { return originRejected; } diff --git a/src/server/mcp/mcp-handler.unit.spec.ts b/src/server/mcp/mcp-handler.unit.spec.ts index 15aad6fa..12a32a59 100644 --- a/src/server/mcp/mcp-handler.unit.spec.ts +++ b/src/server/mcp/mcp-handler.unit.spec.ts @@ -142,6 +142,11 @@ describe('QuickStack MCP handler', () => { expect(response.status).toBe(403); }); + it('accepts a same-origin browser request', async () => { + const response = await buildHandler().fetch(jsonRpcRequest({ method: 'tools/list', origin: 'http://localhost' })); + expect(response.status).toBe(200); + }); + it('derives operations from the routes in deterministic id order', async () => { const { body } = await callTool(buildHandler(), 'search_operations', {}); const operations = body.result.structuredContent.operations as Array<{ operationId: string }>; diff --git a/src/server/mcp/operation-executor.ts b/src/server/mcp/operation-executor.ts index f3b538a4..9071ae08 100644 --- a/src/server/mcp/operation-executor.ts +++ b/src/server/mcp/operation-executor.ts @@ -1,3 +1,4 @@ +import { ServiceException } from '@/shared/model/service.exception.model'; import type { McpSourceApp, OperationDescriptor } from './operation-registry'; export type OperationExecutionResult = { @@ -11,7 +12,7 @@ export function resolveOperationPath(template: string, pathParams: Record { const value = pathParams[name]; if (value === undefined || value === null || value === '') { - throw new Error(`Missing required path parameter "${name}".`); + throw new ServiceException(`Missing required path parameter "${name}".`); } return encodeURIComponent(String(value)); }); @@ -48,7 +49,7 @@ export async function executeOperation(options: { } let requestBody: string | undefined; - if (body !== undefined && operation.method !== 'GET' && operation.method !== 'DELETE') { + if (body !== undefined && operation.method !== 'GET' && operation.method !== 'HEAD') { headers.set('content-type', 'application/json'); requestBody = JSON.stringify(body); } diff --git a/src/server/mcp/operation-registry.ts b/src/server/mcp/operation-registry.ts index cf302750..12a1eab4 100644 --- a/src/server/mcp/operation-registry.ts +++ b/src/server/mcp/operation-registry.ts @@ -97,5 +97,5 @@ export function buildOperationRegistry(app: McpSourceApp): OperationDescriptor[] }); } - return descriptors.sort((left, right) => left.operationId.localeCompare(right.operationId)); + return descriptors.sort((left, right) => left.operationId < right.operationId ? -1 : left.operationId > right.operationId ? 1 : 0); } From ef2b3314ab58361c5c6a61e8569854422ee49f37 Mon Sep 17 00:00:00 2001 From: aperolschpritz Date: Sat, 3 Oct 2026 11:14:54 +0000 Subject: [PATCH 3/9] refactor: convert MCP server factory to a class singleton --- src/server/mcp/mcp-handler.ts | 4 +- src/server/mcp/mcp-server.factory.ts | 246 ++++++++++++++------------- 2 files changed, 132 insertions(+), 118 deletions(-) diff --git a/src/server/mcp/mcp-handler.ts b/src/server/mcp/mcp-handler.ts index 4fbef974..5cfe2206 100644 --- a/src/server/mcp/mcp-handler.ts +++ b/src/server/mcp/mcp-handler.ts @@ -5,7 +5,7 @@ import { } from '@modelcontextprotocol/server'; import type { AuthInfo } from '@modelcontextprotocol/server'; import type { McpAuthResolver } from './mcp-auth'; -import { buildQuickStackMcpServer } from './mcp-server.factory'; +import mcpServerFactory from './mcp-server.factory'; import { buildOperationRegistry } from './operation-registry'; import type { McpSourceApp, OperationDescriptor } from './operation-registry'; @@ -40,7 +40,7 @@ export function createQuickStackMcpHandler(options: QuickStackMcpHandlerOptions) const operations = buildOperationRegistry(app); const handler = createMcpHandler( - ({ authInfo }) => buildQuickStackMcpServer({ app, operations, authInfo }), + ({ authInfo }) => mcpServerFactory.build({ app, operations, authInfo }), { responseMode: 'json' } ); diff --git a/src/server/mcp/mcp-server.factory.ts b/src/server/mcp/mcp-server.factory.ts index 7e4bbfa6..057577e0 100644 --- a/src/server/mcp/mcp-server.factory.ts +++ b/src/server/mcp/mcp-server.factory.ts @@ -60,134 +60,148 @@ const EXECUTE_TOOL_DESCRIPTION = [ 'Responses follow the QuickStack REST API. Errors are returned as tool errors.', ].join(' '); -function serializeOperation(operation: OperationDescriptor, detail: 'name' | 'summary' | 'full') { - const summary = { - operationId: operation.operationId, - method: operation.method, - path: operation.path, - tags: operation.tags, - supported: operation.supported, - ...(detail === 'name' ? {} : { - summary: operation.summary, - description: operation.description, - }), - }; - - if (detail !== 'full') { - return summary; +export type McpServerBuildOptions = { + app: McpSourceApp; + operations: OperationDescriptor[]; + authInfo?: AuthInfo; +}; + +class McpServerFactory { + + build(options: McpServerBuildOptions): McpServer { + const server = new McpServer({ name: 'quickstack', version: '1.0.0' }); + this.registerSearchOperation(server, options.operations); + this.registerExecuteOperation(server, options); + return server; } - return { - ...summary, - pathParamsSchema: operation.pathParamsSchema, - querySchema: operation.querySchema, - bodySchema: operation.bodySchema, - responseSchema: operation.responseSchema, - unsupportedReason: operation.unsupportedReason, - }; -} + private registerSearchOperation(server: McpServer, operations: OperationDescriptor[]): void { + server.registerTool( + 'search_operations', + { + title: 'Search QuickStack operations', + description: SEARCH_TOOL_DESCRIPTION, + inputSchema: searchInputSchema, + 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)); -function matchesSearch(operation: OperationDescriptor, term: string): boolean { - if (!term) { - return true; + return { + content: [{ type: 'text' as const, text: JSON.stringify({ operations: operationList }) }], + structuredContent: { operations: operationList }, + }; + } + ); } - const haystack = [ - operation.operationId, - operation.summary, - operation.description, - ...operation.tags, - ].filter((value): value is string => !!value); - return haystack.some((value) => value.toLowerCase().includes(term)); -} -function errorResult(text: string) { - return { isError: true as const, content: [{ type: 'text' as const, text }] }; -} + private registerExecuteOperation(server: McpServer, options: McpServerBuildOptions): void { + const { app, operations, authInfo } = options; + + server.registerTool( + 'execute_operation', + { + title: 'Execute QuickStack operation', + description: EXECUTE_TOOL_DESCRIPTION, + inputSchema: executeInputSchema, + outputSchema: executeOutputSchema, + annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false }, + }, + async ({ operationId, pathParams, query, body }) => { + const operation = operations.find((candidate) => candidate.operationId === operationId); + if (!operation) { + return this.errorResult(`Unknown operation "${operationId}". Call search_operations to find valid operation ids.`); + } + if (!operation.supported) { + return this.errorResult(`Operation "${operationId}" is not available over MCP. ${operation.unsupportedReason ?? ''}`.trim()); + } + + let execution; + try { + execution = await executeOperation({ + app, + operation, + pathParams: pathParams ?? {}, + query: query ?? {}, + body, + bearerToken: authInfo?.token, + }); + } catch (error) { + return this.errorResult(error instanceof Error ? error.message : 'Operation failed.'); + } + + if (execution.status >= 200 && execution.status < 300) { + const result = execution.result ?? null; + return { + content: [{ type: 'text' as const, text: JSON.stringify(result) }], + structuredContent: { status: execution.status, result }, + }; + } + + return this.errorResult(this.describeFailure(execution.status, execution.result)); + } + ); + } -function 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}`; + private serializeOperation(operation: OperationDescriptor, detail: 'name' | 'summary' | 'full') { + const summary = { + operationId: operation.operationId, + method: operation.method, + path: operation.path, + tags: operation.tags, + supported: operation.supported, + ...(detail === 'name' ? {} : { + summary: operation.summary, + description: operation.description, + }), + }; + + if (detail !== 'full') { + return summary; } + + return { + ...summary, + pathParamsSchema: operation.pathParamsSchema, + querySchema: operation.querySchema, + bodySchema: operation.bodySchema, + responseSchema: operation.responseSchema, + unsupportedReason: operation.unsupportedReason, + }; } - return `QuickStack returned HTTP ${status}.`; -} -export function buildQuickStackMcpServer(options: { - app: McpSourceApp; - operations: OperationDescriptor[]; - authInfo?: AuthInfo; -}): McpServer { - const { app, operations, authInfo } = options; - const server = new McpServer({ name: 'quickstack', version: '1.0.0' }); - - server.registerTool( - 'search_operations', - { - title: 'Search QuickStack operations', - description: SEARCH_TOOL_DESCRIPTION, - inputSchema: searchInputSchema, - outputSchema: searchOutputSchema, - annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: false }, - }, - async ({ search, detail }) => { - const term = search?.trim().toLowerCase() ?? ''; - const operationList = operations - .filter((operation) => matchesSearch(operation, term)) - .map((operation) => serializeOperation(operation, detail)); - - return { - content: [{ type: 'text' as const, text: JSON.stringify({ operations: operationList }) }], - structuredContent: { operations: operationList }, - }; + private matchesSearch(operation: OperationDescriptor, term: string): boolean { + if (!term) { + return true; } - ); - - server.registerTool( - 'execute_operation', - { - title: 'Execute QuickStack operation', - description: EXECUTE_TOOL_DESCRIPTION, - inputSchema: executeInputSchema, - outputSchema: executeOutputSchema, - annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false }, - }, - async ({ operationId, pathParams, query, body }) => { - const operation = operations.find((candidate) => candidate.operationId === operationId); - if (!operation) { - return errorResult(`Unknown operation "${operationId}". Call search_operations to find valid operation ids.`); - } - if (!operation.supported) { - return errorResult(`Operation "${operationId}" is not available over MCP. ${operation.unsupportedReason ?? ''}`.trim()); - } + const haystack = [ + operation.operationId, + operation.summary, + operation.description, + ...operation.tags, + ].filter((value): value is string => !!value); + return haystack.some((value) => value.toLowerCase().includes(term)); + } - let execution; - try { - execution = await executeOperation({ - app, - operation, - pathParams: pathParams ?? {}, - query: query ?? {}, - body, - bearerToken: authInfo?.token, - }); - } catch (error) { - return errorResult(error instanceof Error ? error.message : 'Operation failed.'); - } + private errorResult(text: string) { + return { isError: true as const, content: [{ type: 'text' as const, text }] }; + } - if (execution.status >= 200 && execution.status < 300) { - const result = execution.result ?? null; - return { - content: [{ type: 'text' as const, text: JSON.stringify(result) }], - structuredContent: { status: execution.status, result }, - }; + 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}`; } - - return errorResult(describeFailure(execution.status, execution.result)); } - ); - - return server; + return `QuickStack returned HTTP ${status}.`; + } } + +const mcpServerFactory = new McpServerFactory(); +export default mcpServerFactory; From f612b27355bebe1252c5b78e07bba441e47bbe23 Mon Sep 17 00:00:00 2001 From: aperolschpritz Date: Sat, 3 Oct 2026 11:16:21 +0000 Subject: [PATCH 4/9] docs: add remote MCP server spec --- .scratch/remote-mcp-server/PRD.md | 221 ++++++++++++++++++++++++++++++ 1 file changed, 221 insertions(+) create mode 100644 .scratch/remote-mcp-server/PRD.md diff --git a/.scratch/remote-mcp-server/PRD.md b/.scratch/remote-mcp-server/PRD.md new file mode 100644 index 00000000..f400b555 --- /dev/null +++ b/.scratch/remote-mcp-server/PRD.md @@ -0,0 +1,221 @@ +# Remote MCP Server for QuickStack + +Status: ready-for-agent + +## Problem Statement + +QuickStack exposes a REST API under `/api/v1` that a **REST API Client** can drive with a **REST API Key**. Users who work through AI hosts (Claude Code, Cursor, VS Code) currently have no way to let an agent read or change QuickStack state. Those hosts speak the Model Context Protocol (MCP), not the QuickStack REST API. + +A naive MCP bridge would expose one tool per REST endpoint. The API already has roughly two dozen endpoints across Projects, Apps, Agents, and Agent Sandboxes. Loading two dozen tool definitions into the model's context before the conversation starts is expensive and makes the model slower and less accurate. A second naive approach would re-declare every input and output schema as MCP tool definitions, duplicating schemas that already exist and drifting from the REST API over time. + +QuickStack needs a remote MCP endpoint that mirrors the existing REST API actions, reuses existing schemas and handlers instead of duplicating them, and keeps the number of tools the model must load extremely small. + +## Solution + +Add a remote MCP endpoint to QuickStack that is generated from the existing REST routes. + +The server inspects the existing Elysia REST route definitions at startup and builds an **operation catalog** from their already-declared metadata: `detail.operationId`, `detail.summary`, `detail.tags`, and the `params`, `query`, `body`, and success-response schemas. It then exposes exactly two MCP tools: + +1. **`search_operations`** - the model discovers available operations on demand. It supports a free-text `search` term and a `detail` level (`name`, `summary`, `full`) so the model can progressively pull in only the information it needs. +2. **`execute_operation`** - the model names an operation and supplies arguments. The server translates the call into an internal request against the existing Elysia app, so validation, the **Shared Authorization Check**, error mapping, and business logic run exactly as they do for the REST API. + +Because execution goes through the existing app, a new REST route automatically becomes an available operation with no additional MCP code. Authentication reuses the existing **REST API Key** logic: the MCP client sends the same bearer credential it would send to the REST API. + +This keeps the MCP surface tiny (two tools), avoids schema duplication, and preserves a single source of truth for QuickStack operations. + +## User Stories + +1. As a QuickStack administrator, I want a remote MCP endpoint under the QuickStack origin, so that AI hosts can connect to QuickStack without me deploying a separate service. +2. As a QuickStack administrator, I want the MCP endpoint to authenticate with an existing **REST API Key**, so that I do not have to stand up a new identity provider. +3. As a QuickStack administrator, I want MCP access to honor exactly the same permissions as the REST API, so that an agent cannot do more than the owning user can. +4. As a QuickStack administrator, I want an invalid or expired **REST API Key** to be rejected before any operation runs, so that unauthenticated requests fail fast. +5. As a QuickStack user, I want to create a **REST API Key** for my MCP client, so that I can connect an AI host on my behalf. +6. As a QuickStack user, I want the MCP client to present its **REST API Key** as a bearer credential, so that configuration matches the REST API I already use. +7. As an agent, I want `tools/list` to return a very small, fixed set of tools, so that connecting to QuickStack does not consume large amounts of context. +8. As an agent, I want a `search_operations` tool, so that I can discover what QuickStack can do without loading every operation definition. +9. As an agent, I want `search_operations` to match on operation identifier, summary, and tags, so that I can find the relevant operation with a natural query. +10. As an agent, I want `search_operations` to support a `name` detail level, so that I can browse cheaply when context is tight. +11. As an agent, I want `search_operations` to support a `summary` detail level, so that I can see method, path, and description without full schemas. +12. As an agent, I want `search_operations` to support a `full` detail level, so that I can fetch the exact input and output schema for the operation I have chosen. +13. As an agent, I want operation results to be deterministic in order, so that repeated discovery produces stable output and prompt caching can work. +14. As an agent, I want to execute an operation by its identifier, so that I do not need a distinct tool per endpoint. +15. As an agent, I want `execute_operation` to accept path parameters, query parameters, and a request body as separate fields, so that the mapping back to the REST route is unambiguous. +16. As an agent, I want `execute_operation` to return the operation result as structured content plus a human-readable text mirror, so that I can both parse and read the result. +17. As an agent, I want a successful operation to return the **Direct Success Payload** from the REST API, so that MCP output matches REST output. +18. As an agent, I want a failed operation to return an actionable tool error message, so that I can correct my arguments and retry. +19. As an agent, I want a **Problem Details Error** from the REST API to be surfaced as an MCP tool error rather than a transport failure, so that the model can read the reason. +20. As an agent, I want an unknown operation identifier to produce an actionable error listing no such operation, so that I do not loop on a typo. +21. As an agent, I want an operation whose input fails validation to return a validation message, so that I can fix the payload. +22. As an agent, I want to list **Projects**, so that I can present and choose a project. +23. As an agent, I want to get a single **Project**, so that I can inspect its details. +24. As an agent, I want to create or update a **Project**, so that I can set up a workspace in QuickStack. +25. As an agent, I want to delete a **Project**, so that I can clean up when authorized. +26. As an agent, I want to list **Apps**, optionally filtered by **Project**, so that I can reason about an **App Project**. +27. As an agent, I want to get a single **App**, so that I can inspect its configuration. +28. As an agent, I want to create or update an **App** using **POST Upsert**, so that the write semantics match the REST API. +29. As an agent, I want to receive the same **Full Schema Write** validation as REST when saving an **App**, so that MCP writes cannot produce invalid state. +30. As an agent, I want to delete an **App**, so that I can remove a workload when authorized. +31. As an agent, I want to trigger a **Build-And-Deploy Action** for an **App**, so that I can ship a new build. +32. As an agent, I want to list an **App**'s deployments and read a deployment's logs, so that I can diagnose a rollout. +33. As an agent, I want to read the current logs of an **App**, so that I can debug a running workload. +34. As an agent, I want to list **Agents**, optionally filtered by **Project**, so that I can reason about an **Agent Project**. +35. As an agent, I want to get, create, update, and delete an **Agent**, so that I can manage agent definitions. +36. As an agent, I want to trigger a **Deploy (Agent)**, so that I can reconcile an **Agent** configuration to Kubernetes. +37. As an agent, I want to list, start, get, and stop **Agent Sandboxes**, so that I can manage running instances of an **Agent**. +38. As an agent, I want to run a shell command in an **Agent Sandbox**, so that I can drive an agent workspace. +39. As a QuickStack maintainer, I want a new REST route to appear as an operation automatically, so that I do not write MCP code per endpoint. +40. As a QuickStack maintainer, I want input and output schemas to come from the existing route definitions, so that MCP can never drift from REST. +41. As a QuickStack maintainer, I want the MCP endpoint to live inside the existing Next.js application, so that no new deployment target or custom server change is required. +42. As a QuickStack maintainer, I want the MCP endpoint to reject requests with an unexpected Host or Origin, so that DNS-rebinding attacks cannot reach it. +43. As a QuickStack maintainer, I want the API key never to be logged, echoed, or embedded in tool results, so that credentials cannot leak into model context or logs. +44. As a QuickStack maintainer, I want binary and streaming operations excluded from the catalog in the first version, so that the JSON-based tool contract stays well defined. +45. As a QuickStack maintainer, I want operation catalog caching to be user-scoped, so that one user's operation list is never served to another. +46. As a QuickStack maintainer, I want the MCP endpoint to be stateless per request, so that it scales behind the existing load balancer without sticky sessions. +47. As a QuickStack maintainer, I want the MCP implementation to target the current MCP specification revision, so that it works with modern hosts without deprecated transports. +48. As a QuickStack maintainer, I want the MCP endpoint to work as a web-standard request handler, so that it composes with the existing Next.js routing without adapters. +49. As a QuickStack maintainer, I want tests to exercise the MCP endpoint through its HTTP handler, so that tests assert external behavior instead of internals. + +## Implementation Decisions + +### Target specification and SDK + +- Target the current MCP specification revision (`2026-07-28`) using the stable v2 TypeScript SDK package `@modelcontextprotocol/server`. +- Use `createMcpHandler(factory)` to produce a web-standard `(Request) => Promise` handler, and `McpServer.registerTool(...)` to register tools. +- Mount the handler as a Next.js route handler that exports the web-standard verb functions, so no custom-server change is needed. The route is dynamic and must not be statically optimized. + +### Operation catalog derived from existing routes + +- Build the catalog by introspecting the existing Elysia REST app's route table. Each `InternalRoute` exposes `method`, `path`, and `hooks`. The hooks hold `body`, `query`, `params`, `response`, and `detail`. +- For each route, derive: + - `operationId` from `hooks.detail.operationId` (every REST route already declares one). + - `summary` and `description` from `hooks.detail.summary` / `hooks.detail.description`. + - `tags` from `hooks.detail.tags`. + - `method` and `path` from the route. + - Input schema by merging `hooks.params`, `hooks.query`, and `hooks.body` into one JSON object schema, tracking the origin (`pathParams`, `query`, or `body`) of each property so `execute_operation` can split arguments back apart. + - Output schema from the 200 response schema in `hooks.response`, reusing the existing date-to-string mapping used for REST response models. +- Convert schemas to JSON Schema 2020-12 with the same conversion the OpenAPI plugin already uses for these Zod schemas. +- Fallback decision: if Elysia does not expose raw schemas at runtime, consume the already-generated OpenAPI document as the schema source and keep `app.handle` for execution. The introspection path is preferred because it does not depend on the OpenAPI feature flag. +- The catalog is a pure function of the Elysia app: no per-endpoint code. New REST routes appear automatically. + +### The two tools + +`search_operations`: + +```ts +inputSchema: z.object({ + search: z.string().optional(), // match operationId, summary, tags + detail: z.enum(['name', 'summary', 'full']).default('summary') +}) +outputSchema: z.object({ + operations: z.array(z.object({ + operationId: z.string(), + method: z.string(), + path: z.string(), + summary: z.string().optional(), + tags: z.array(z.string()), + inputSchema: z.unknown().optional(), // only at `full` + outputSchema: z.unknown().optional() // only at `full` + })) +}) +``` + +`execute_operation`: + +```ts +inputSchema: z.object({ + operationId: z.string(), + pathParams: z.record(z.unknown()).default({}), + query: z.record(z.unknown()).default({}), + body: z.unknown().optional() +}) +outputSchema: z.object({ + status: z.number().int(), + result: z.unknown() +}) +``` + +- `search_operations` is annotated read-only and idempotent. `execute_operation` is not read-only. +- Tool names are namespaced with a `quickstack_` prefix or equivalent to avoid collisions when a host connects several servers. +- Descriptions are written for an agent: they explain what QuickStack is, when to search first, and that arguments map to the REST route fields. + +### Execution and reuse + +- `execute_operation` resolves the `operationId` against the catalog. An unknown identifier is an MCP tool error, never a 500. +- The executor substitutes `pathParams` into the path template, appends `query` as a query string, and serializes `body` as JSON. +- The executor dispatches through the existing Elysia app's `handle(request)` method. This reuses parsing, validation, the **Shared Authorization Check**, the central error mapper, and all business logic. +- Only catalog entries may be executed. The path template is server-owned, so a caller cannot inject an arbitrary path. +- The bearer credential from the MCP request is forwarded to the internal request so the existing authentication derives the same **REST API Client** identity. +- Binary and streaming routes (file read/write, any octet-stream response) are marked unsupported in the catalog and return an actionable tool error instead of being executed in the first version. + +### Authentication + +- Authenticate the MCP endpoint with the existing **REST API Key** logic. Reuse the helper that resolves a bearer header to a requester identity; do not introduce a second credential type. +- A missing, malformed, or invalid key returns 401 before any tool runs. +- The verified identity is passed to the MCP handler as pass-through auth info; tool handlers read the raw token from it to build the internal request. +- Known tradeoff: the key is resolved once at the MCP gate and again by the REST derivation. Accept the duplicate lookup in the first version; it is stateless and simple. A later optimization may hand the resolved identity across the boundary. + +### Response shaping and caching + +- `search_operations` and `tools/list` output is deterministic and sorted by `operationId`. +- The operation list is user-scoped, so its cache scope is private. +- The server is stateless per request; the handler may be run behind the existing load balancer without sticky sessions. + +### Security + +- Validate Host and Origin in front of the handler and reject mismatches with 403. +- Never include the API key in tool output, errors, or logs. +- Validate `execute_operation` input against the derived JSON schema before dispatch; the Elysia layer validates again. +- Do not enable model-controlled headers or arbitrary URL routing. + +### Module responsibilities (interfaces, not paths) + +- **Operation registry**: given an Elysia app, return an ordered list of operation descriptors. Pure and side-effect free. +- **Operation executor**: given a descriptor and arguments, build and dispatch the internal request through the app; map the response to a tool result. +- **MCP auth gate**: given a web-standard request, return a verified identity or a 401 response. +- **MCP server factory**: build an `McpServer` for one request, registering the two tools with schemas and handlers. +- **MCP HTTP route**: compose host/origin validation, the auth gate, and the web-standard handler. + +### Architectural alignment + +- Reuses the single source of truth for operations (the REST routes) and the single authorization path (`shared-authorization.utils`). +- Does not change REST behavior, schemas, or error contract. +- No conflict with existing ADRs. Operations that perform **Full Schema Write** are forwarded unchanged; the MCP layer adds no alternate write semantics. + +## Testing Decisions + +What makes a good test here: exercise the MCP endpoint as a black box over JSON-RPC. Construct a web-standard request, send `tools/list` and `tools/call`, and assert the JSON-RPC response. Do not assert registry internals, private helpers, or the exact wording of derived schemas beyond what a client observes. Test only external behavior. + +Highest seam: the **MCP server factory** accepts the source Elysia app and the auth resolver as inputs. Tests build the handler from a small fixture Elysia app that uses the same route idioms (Zod `params`/`query`/`body`, `detail.operationId`, `detail.summary`, response model) and the same authentication derivation, with services mocked. They then call `handler.fetch(request)` and assert results. This is one new seam; it reuses the existing `app.handle` seam underneath. Aim to keep the codebase at this single new seam. + +Existing seam reused: the Elysia app's `handle(request)` method, already used by the existing route unit test. + +Modules to test: + +- Operation registry, through the MCP handler (`tools/list` shape, determinism, search filtering, detail levels). +- MCP auth gate (missing/invalid/valid **REST API Key**). +- Operation executor, through the MCP handler (success, validation failure, not-found, unknown operation, binary route rejection). +- MCP server factory, through the MCP handler (tool registration and schemas). +- Host/Origin validation in front of the handler. + +Prior art: + +- The existing agent sandbox route unit test drives routes with `app.handle(new Request(...))` and mocks services with `vi.mock`, which is the model for handler-level tests. +- Unit tests live next to source as `*.unit.spec.ts` and run under the Vitest `jsdom` project; integration tests live under `src/__tests__/integration/` and run under the `node-integration` project. + +## Out of Scope + +- OAuth 2.1 resource-server authentication with an external identity provider, protected-resource metadata, and audience validation. +- Local stdio transport, desktop bundle packaging, and publishing to an MCP registry. +- Binary and streaming operations (file read/write, octet-stream responses) in the first version. +- Per-operation scopes, step-up authorization, and operation-specific named tools. +- `x-mcp-header` parameter mirroring and gateway header routing. +- MCP resources, resource templates, prompts, and MCP Apps user interfaces. +- Changing any REST API route, schema, authorization rule, or error contract. +- Server-side state, sessions, and long-lived change subscriptions. + +## Further Notes + +- Variant context: four approaches were considered. (A) a generic OpenAPI-to-MCP bridge; (B) an off-the-shelf Elysia MCP adapter; (C) this in-house route adapter on the v2 SDK; (D) hand-written curated tools on the v2 SDK. This spec selects C because it targets the current specification, keeps a tiny external surface, and maximizes reuse of existing routes and services. D remains a possible later step if curated, task-oriented tools prove valuable; B was rejected because available adapters target the older v1 SDK and expose one tool per route; A was rejected because it mirrors the API one tool per endpoint and requires the OpenAPI feature flag. +- Context-efficiency rationale: Anthropic's "Code execution with MCP" guidance recommends exposing a `search_tools`-style meta-tool with a detail level and loading definitions on demand; a representative example reduced token usage from roughly 150,000 to 2,000. This design applies that pattern with two tools. +- Assumption to verify early: the exact runtime shape of Elysia route `hooks` for Zod schemas. If raw schemas are unavailable, fall back to the generated OpenAPI document as described in the implementation decisions. +- Domain vocabulary used throughout: **Project**, **App**, **Agent**, **Agent Sandbox**, **Project Workload**, **REST API Key**, **REST API Client**, **Shared Authorization Check**, **Build-And-Deploy Action**, **Deploy (Agent)**, **Direct Success Payload**, **Problem Details Error**, **POST Upsert**, **Full Schema Write**. From 0d542144da02ce81560ddf780fd0d21594109f52 Mon Sep 17 00:00:00 2001 From: aperolschpritz Date: Sat, 3 Oct 2026 11:17:36 +0000 Subject: [PATCH 5/9] refactor: convert MCP operation registry and executor to class singletons --- src/server/mcp/mcp-handler.ts | 4 +- src/server/mcp/mcp-server.factory.ts | 4 +- src/server/mcp/operation-executor.ts | 106 ++++++++++++++------------- src/server/mcp/operation-registry.ts | 102 +++++++++++++------------- 4 files changed, 113 insertions(+), 103 deletions(-) diff --git a/src/server/mcp/mcp-handler.ts b/src/server/mcp/mcp-handler.ts index 5cfe2206..ca4f9571 100644 --- a/src/server/mcp/mcp-handler.ts +++ b/src/server/mcp/mcp-handler.ts @@ -6,7 +6,7 @@ import { import type { AuthInfo } from '@modelcontextprotocol/server'; import type { McpAuthResolver } from './mcp-auth'; import mcpServerFactory from './mcp-server.factory'; -import { buildOperationRegistry } from './operation-registry'; +import operationRegistry from './operation-registry'; import type { McpSourceApp, OperationDescriptor } from './operation-registry'; export type QuickStackMcpHandlerOptions = { @@ -37,7 +37,7 @@ function unauthorizedResponse(): Response { export function createQuickStackMcpHandler(options: QuickStackMcpHandlerOptions): QuickStackMcpHandler { const { app, resolveAuthInfo, allowedOrigins = [], allowedHosts } = options; - const operations = buildOperationRegistry(app); + const operations = operationRegistry.build(app); const handler = createMcpHandler( ({ authInfo }) => mcpServerFactory.build({ app, operations, authInfo }), diff --git a/src/server/mcp/mcp-server.factory.ts b/src/server/mcp/mcp-server.factory.ts index 057577e0..95730ae0 100644 --- a/src/server/mcp/mcp-server.factory.ts +++ b/src/server/mcp/mcp-server.factory.ts @@ -1,7 +1,7 @@ import { McpServer } from '@modelcontextprotocol/server'; import type { AuthInfo } from '@modelcontextprotocol/server'; import { z } from 'zod'; -import { executeOperation } from './operation-executor'; +import operationExecutor from './operation-executor'; import type { McpSourceApp, OperationDescriptor } from './operation-registry'; const searchInputSchema = z.object({ @@ -122,7 +122,7 @@ class McpServerFactory { let execution; try { - execution = await executeOperation({ + execution = await operationExecutor.execute({ app, operation, pathParams: pathParams ?? {}, diff --git a/src/server/mcp/operation-executor.ts b/src/server/mcp/operation-executor.ts index 9071ae08..58fc27b5 100644 --- a/src/server/mcp/operation-executor.ts +++ b/src/server/mcp/operation-executor.ts @@ -6,69 +6,77 @@ export type OperationExecutionResult = { result: unknown; }; -const PATH_PARAM_PATTERN = /:([A-Za-z0-9_]+)/g; - -export function resolveOperationPath(template: string, pathParams: Record): string { - return template.replace(PATH_PARAM_PATTERN, (_match, name: string) => { - const value = pathParams[name]; - if (value === undefined || value === null || value === '') { - throw new ServiceException(`Missing required path parameter "${name}".`); - } - return encodeURIComponent(String(value)); - }); -} - -export async function executeOperation(options: { +export type OperationExecutionOptions = { app: McpSourceApp; operation: OperationDescriptor; pathParams: Record; query: Record; body: unknown; bearerToken?: string; -}): Promise { - const { app, operation, pathParams, query, body, bearerToken } = options; - const path = resolveOperationPath(operation.path, pathParams); - const url = new URL(path, 'http://quickstack.local'); +}; - for (const [key, value] of Object.entries(query ?? {})) { - if (value === undefined || value === null) { - continue; - } - if (Array.isArray(value)) { - for (const item of value) { - url.searchParams.append(key, String(item)); +const PATH_PARAM_PATTERN = /:([A-Za-z0-9_]+)/g; + +class OperationExecutor { + + async execute(options: OperationExecutionOptions): Promise { + const { app, operation, pathParams, query, body, bearerToken } = options; + const path = this.resolvePath(operation.path, pathParams); + const url = new URL(path, 'http://quickstack.local'); + + for (const [key, value] of Object.entries(query ?? {})) { + if (value === undefined || value === null) { + continue; + } + if (Array.isArray(value)) { + for (const item of value) { + url.searchParams.append(key, String(item)); + } + } else { + url.searchParams.set(key, String(value)); } - } else { - url.searchParams.set(key, String(value)); } - } - const headers = new Headers(); - if (bearerToken) { - headers.set('authorization', `Bearer ${bearerToken}`); - } + const headers = new Headers(); + if (bearerToken) { + headers.set('authorization', `Bearer ${bearerToken}`); + } - let requestBody: string | undefined; - if (body !== undefined && operation.method !== 'GET' && operation.method !== 'HEAD') { - headers.set('content-type', 'application/json'); - requestBody = JSON.stringify(body); - } + let requestBody: string | undefined; + if (body !== undefined && operation.method !== 'GET' && operation.method !== 'HEAD') { + headers.set('content-type', 'application/json'); + requestBody = JSON.stringify(body); + } - const response = await app.handle(new Request(url, { - method: operation.method, - headers, - body: requestBody, - })); + const response = await app.handle(new Request(url, { + method: operation.method, + headers, + body: requestBody, + })); - const text = await response.text(); - let result: unknown = undefined; - if (text) { - try { - result = JSON.parse(text); - } catch { - result = text; + const text = await response.text(); + let result: unknown = undefined; + if (text) { + try { + result = JSON.parse(text); + } catch { + result = text; + } } + + return { status: response.status, result }; } - return { status: response.status, result }; + private resolvePath(template: string, pathParams: Record): string { + return template.replace(PATH_PARAM_PATTERN, (_match, name: string) => { + const value = pathParams[name]; + if (value === undefined || value === null || value === '') { + throw new ServiceException(`Missing required path parameter "${name}".`); + } + return encodeURIComponent(String(value)); + }); + } } + +const operationExecutor = new OperationExecutor(); +export default operationExecutor; diff --git a/src/server/mcp/operation-registry.ts b/src/server/mcp/operation-registry.ts index 12a1eab4..435c8746 100644 --- a/src/server/mcp/operation-registry.ts +++ b/src/server/mcp/operation-registry.ts @@ -38,64 +38,66 @@ type ElysiaRoute = { }; }; -function toJsonSchema(schema: unknown): Record | undefined { - if (!schema || typeof schema !== 'object' || !('~standard' in schema)) { - return undefined; - } - - try { - return toJsonSchemaUnsafe(schema); - } catch { - return undefined; - } -} +class OperationRegistry { -function toJsonSchemaUnsafe(schema: unknown): Record { - return z.toJSONSchema(schema as z.ZodType, { unrepresentable: 'any' }) as Record; -} + build(app: McpSourceApp): OperationDescriptor[] { + const routes = (app.routes ?? []) as ElysiaRoute[]; + const seen = new Set(); + const descriptors: OperationDescriptor[] = []; -export function buildOperationRegistry(app: McpSourceApp): OperationDescriptor[] { - const routes = (app.routes ?? []) as ElysiaRoute[]; - const seen = new Set(); - const descriptors: OperationDescriptor[] = []; + for (const route of routes) { + const detail = route.hooks?.detail; + const operationId = detail?.operationId; + if (!operationId || seen.has(operationId)) { + continue; + } + seen.add(operationId); - for (const route of routes) { - const detail = route.hooks?.detail; - const operationId = detail?.operationId; - if (!operationId || seen.has(operationId)) { - continue; - } - seen.add(operationId); + const responseSchema = route.hooks?.response?.['200']; + const manualBodyParsing = (route.hooks?.parse ?? []).some((entry) => entry?.fn === 'none'); - const responseSchema = route.hooks?.response?.['200']; - const manualBodyParsing = (route.hooks?.parse ?? []).some((entry) => entry?.fn === 'none'); + let supported = true; + let unsupportedReason: string | undefined; - let supported = true; - let unsupportedReason: string | undefined; + if (!responseSchema) { + supported = false; + unsupportedReason = 'Operation does not declare a JSON response.'; + } else if (manualBodyParsing) { + supported = false; + unsupportedReason = 'Operation consumes a non-JSON request body.'; + } - if (!responseSchema) { - supported = false; - unsupportedReason = 'Operation does not declare a JSON response.'; - } else if (manualBodyParsing) { - supported = false; - unsupportedReason = 'Operation consumes a non-JSON request body.'; + descriptors.push({ + operationId, + method: route.method, + path: route.path, + summary: detail?.summary, + description: detail?.description, + tags: detail?.tags ?? [], + pathParamsSchema: this.toJsonSchema(route.hooks?.params), + querySchema: this.toJsonSchema(route.hooks?.query), + bodySchema: this.toJsonSchema(route.hooks?.body), + responseSchema: this.toJsonSchema(responseSchema), + supported, + unsupportedReason, + }); } - descriptors.push({ - operationId, - method: route.method, - path: route.path, - summary: detail?.summary, - description: detail?.description, - tags: detail?.tags ?? [], - pathParamsSchema: toJsonSchema(route.hooks?.params), - querySchema: toJsonSchema(route.hooks?.query), - bodySchema: toJsonSchema(route.hooks?.body), - responseSchema: toJsonSchema(responseSchema), - supported, - unsupportedReason, - }); + return descriptors.sort((left, right) => left.operationId < right.operationId ? -1 : left.operationId > right.operationId ? 1 : 0); } - return descriptors.sort((left, right) => left.operationId < right.operationId ? -1 : left.operationId > right.operationId ? 1 : 0); + private toJsonSchema(schema: unknown): Record | undefined { + if (!schema || typeof schema !== 'object' || !('~standard' in schema)) { + return undefined; + } + + try { + return z.toJSONSchema(schema as z.ZodType, { unrepresentable: 'any' }) as Record; + } catch { + return undefined; + } + } } + +const operationRegistry = new OperationRegistry(); +export default operationRegistry; From da129083fb4df4e30f4eed7aebb7eb10874043c0 Mon Sep 17 00:00:00 2001 From: aperolschpritz Date: Sat, 3 Oct 2026 11:24:40 +0000 Subject: [PATCH 6/9] feat: add MCP server settings page with enablement toggle and client guides --- src/app/api/mcp/route.ts | 22 +- src/app/api/mcp/route.unit.spec.ts | 44 ++++ src/app/settings/actions.ts | 10 + .../settings/developer/mcp/mcp-settings.tsx | 213 ++++++++++++++++++ src/app/settings/developer/mcp/page.tsx | 12 + src/server/services/param.service.ts | 2 + src/shared/utils/settings-navigation.ts | 2 + 7 files changed, 302 insertions(+), 3 deletions(-) create mode 100644 src/app/api/mcp/route.unit.spec.ts create mode 100644 src/app/settings/developer/mcp/mcp-settings.tsx create mode 100644 src/app/settings/developer/mcp/page.tsx diff --git a/src/app/api/mcp/route.ts b/src/app/api/mcp/route.ts index f2bef92e..1f8e7418 100644 --- a/src/app/api/mcp/route.ts +++ b/src/app/api/mcp/route.ts @@ -1,6 +1,7 @@ import { v1Api } from '@/server/api/v1/api-index'; import { resolveQuickStackAuthInfo } from '@/server/mcp/mcp-auth'; import { createQuickStackMcpHandler } from '@/server/mcp/mcp-handler'; +import paramService, { ParamService } from '@/server/services/param.service'; export const dynamic = 'force-dynamic'; @@ -9,6 +10,21 @@ const mcpHandler = createQuickStackMcpHandler({ resolveAuthInfo: resolveQuickStackAuthInfo, }); -export const POST = mcpHandler.fetch; -export const GET = mcpHandler.fetch; -export const DELETE = mcpHandler.fetch; +async function handleRequest(request: Request): Promise { + const enabled = await paramService.getBoolean(ParamService.MCP_SERVER_ENABLED); + if (!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 mcpHandler.fetch(request); +} + +export const POST = handleRequest; +export const GET = handleRequest; +export const DELETE = handleRequest; diff --git a/src/app/api/mcp/route.unit.spec.ts b/src/app/api/mcp/route.unit.spec.ts new file mode 100644 index 00000000..b3d8a014 --- /dev/null +++ b/src/app/api/mcp/route.unit.spec.ts @@ -0,0 +1,44 @@ +const routeMocks = vi.hoisted(() => ({ + getBoolean: vi.fn(), + mcpFetch: vi.fn(), +})); + +vi.mock('@/server/services/param.service', () => ({ + default: { getBoolean: routeMocks.getBoolean }, + ParamService: { MCP_SERVER_ENABLED: 'mcpServerEnabled' }, +})); + +vi.mock('@/server/api/v1/api-index', () => ({ v1Api: {} })); + +vi.mock('@/server/mcp/mcp-auth', () => ({ resolveQuickStackAuthInfo: vi.fn() })); + +vi.mock('@/server/mcp/mcp-handler', () => ({ + createQuickStackMcpHandler: () => ({ fetch: routeMocks.mcpFetch }), +})); + +import { POST } from './route'; + +describe('MCP route enablement', () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + + it('rejects requests when the MCP server is disabled', async () => { + routeMocks.getBoolean.mockResolvedValue(false); + + const response = await POST(new Request('http://localhost/api/mcp', { method: 'POST' })); + + expect(response.status).toBe(404); + expect(routeMocks.mcpFetch).not.toHaveBeenCalled(); + }); + + it('delegates to the MCP handler when the MCP server is enabled', async () => { + routeMocks.getBoolean.mockResolvedValue(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); + }); +}); diff --git a/src/app/settings/actions.ts b/src/app/settings/actions.ts index 5b0ffad3..5e700522 100644 --- a/src/app/settings/actions.ts +++ b/src/app/settings/actions.ts @@ -272,6 +272,16 @@ export const setOpenApiSpecEnabled = async (enabled: boolean) => return new SuccessActionResult(undefined, `Turned ${enabled ? 'on' : 'off'} OpenAPI spec access.`); }); +export const setMcpServerEnabled = async (enabled: boolean) => + simpleAction(async () => { + await getAdminUserSession(); + await paramService.save({ + name: ParamService.MCP_SERVER_ENABLED, + value: enabled ? 'true' : 'false' + }); + return new SuccessActionResult(undefined, `Turned ${enabled ? 'on' : 'off'} the MCP server.`); + }); + export const setRegistryStorageLocation = async (prevState: any, inputData: RegistryStorageLocationSettingsModel) => saveFormAction(inputData, registryStorageLocationSettingsZodModel, async (validatedData) => { await getAdminUserSession(); diff --git a/src/app/settings/developer/mcp/mcp-settings.tsx b/src/app/settings/developer/mcp/mcp-settings.tsx new file mode 100644 index 00000000..1ec95274 --- /dev/null +++ b/src/app/settings/developer/mcp/mcp-settings.tsx @@ -0,0 +1,213 @@ +'use client'; + +import { useEffect, useState, type ReactNode } from 'react'; +import { toast } from 'sonner'; +import { Copy } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Label } from '@/components/ui/label'; +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'; + +function Snippet({ value }: { value: string }) { + return ( +
+ +
+                {value}
+            
+
+ ); +} + +function Step({ title, children }: { title: string; children: ReactNode }) { + return ( +
+

{title}

+ {children} +
+ ); +} + +export default function McpSettings({ mcpEnabled }: { mcpEnabled: boolean }) { + const [isEnabled, setIsEnabled] = useState(mcpEnabled); + const [loading, setLoading] = useState(false); + const [origin, setOrigin] = useState(''); + + useEffect(() => { + setOrigin(window.location.origin); + }, []); + + const mcpUrl = `${origin || 'https://'}/api/mcp`; + + const handleToggle = async (checked: boolean) => { + const previousState = isEnabled; + try { + setLoading(true); + setIsEnabled(checked); + + await Toast.fromAction( + () => setMcpServerEnabled(checked), + `MCP server ${checked ? 'enabled' : 'disabled'}`, + `Updating MCP server...` + ); + } catch { + setIsEnabled(previousState); + } finally { + setLoading(false); + } + }; + + 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 ( +
+ + + MCP Server + + Expose QuickStack as a remote Model Context Protocol server. AI clients such as Claude Code, + Codex and GitHub Copilot can then act on QuickStack through the REST API using a REST API Key. + + + +
+
+ + +
+ {loading && } +
+
+
+ + {isEnabled && + + 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. + + + + + +

+ Send the key as Authorization: Bearer <key>. The server follows + the MCP Streamable HTTP transport and is stateless. +

+
+
} + + {isEnabled && + + Client setup + Add QuickStack as a remote MCP server in your AI client. + + + + + Claude Code + Codex + GitHub Copilot + Generic + + + + + + + + + + + + + + + + + + + + + + + + + +

+ VS Code also reads a portable .mcp.json at the workspace root, + which uses the same mcpServers shape as Claude Code. +

+
+ + + + + +

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

+
+
+
+
} +
+ ); +} diff --git a/src/app/settings/developer/mcp/page.tsx b/src/app/settings/developer/mcp/page.tsx new file mode 100644 index 00000000..ba715a7f --- /dev/null +++ b/src/app/settings/developer/mcp/page.tsx @@ -0,0 +1,12 @@ +import paramService, { ParamService } from "@/server/services/param.service" +import { getAdminUserSession } from "@/server/utils/action-wrapper.utils" +import { ServerSettingsPage } from "../../server-settings-page" +import McpSettings from "./mcp-settings" + +export default async function McpSettingsPage() { + await getAdminUserSession() + const mcpEnabled = await paramService.getBoolean(ParamService.MCP_SERVER_ENABLED) + return + + +} diff --git a/src/server/services/param.service.ts b/src/server/services/param.service.ts index 3f200ea1..449b6833 100644 --- a/src/server/services/param.service.ts +++ b/src/server/services/param.service.ts @@ -23,6 +23,7 @@ export class ParamService { static readonly MAX_PARALLEL_BUILDS = 'maxParallelBuilds'; static readonly QS_INSTANCE_ID = 'qsInstanceId'; static readonly API_OPEN_API_SPEC_ENABLED = 'apiOpenApiSpecEnabled'; + static readonly MCP_SERVER_ENABLED = 'mcpServerEnabled'; static readonly AGENT_JWT_SECRET = 'agentJwtSecret'; static readonly LATEST_COMPLETED_CODE_MIGRATION = 'latestCompletedCodeMigration'; static readonly FEATURE_NEW_NETWORK_POLICY_EXPLENATION = 'featureNewNetworkPolicyExplenation'; @@ -36,6 +37,7 @@ export class ParamService { this.getOrCreate(ParamService.QS_SYSTEM_BACKUP_LOCATION, Constants.QS_SYSTEM_BACKUP_DEACTIVATED, revalidateParam), 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, 'true', revalidateParam), ]); return { instanceId, registryLocation }; diff --git a/src/shared/utils/settings-navigation.ts b/src/shared/utils/settings-navigation.ts index e566ee0b..4f319b10 100644 --- a/src/shared/utils/settings-navigation.ts +++ b/src/shared/utils/settings-navigation.ts @@ -1,4 +1,5 @@ import { + Bot, Box, Boxes, ChevronsLeftRightEllipsis, @@ -62,6 +63,7 @@ export const serverSettingsNavigation: ServerSettingsNavigationItem[] = [ export const developerSettingsNavigation: SettingsNavigationItem[] = [ { title: "REST API", href: "/settings/developer/rest-api", icon: ChevronsLeftRightEllipsis, adminOnly: true }, + { title: "MCP Server", href: "/settings/developer/mcp", icon: Bot, adminOnly: true }, ] export function serverSettingsHref(tab: string) { From 398d567ae811a1b8206d786e7caf44ad0461fe4e Mon Sep 17 00:00:00 2001 From: aperolschpritz Date: Sat, 3 Oct 2026 11:46:44 +0000 Subject: [PATCH 7/9] feat: split MCP execution into read-only and mutating tools --- .../mcp/mcp-handler.integration.spec.ts | 4 +- src/server/mcp/mcp-handler.unit.spec.ts | 38 ++++- src/server/mcp/mcp-server.factory.ts | 154 ++++++++++++------ src/server/mcp/operation-registry.ts | 6 + 4 files changed, 142 insertions(+), 60 deletions(-) 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 e5937c45..278e95e4 100644 --- a/src/__tests__/integration/server/mcp/mcp-handler.integration.spec.ts +++ b/src/__tests__/integration/server/mcp/mcp-handler.integration.spec.ts @@ -104,9 +104,9 @@ describe('QuickStack MCP integration', () => { const response = await handler.fetch(jsonRpcRequest({ method: 'tools/call', - toolName: 'execute_operation', + toolName: 'execute_read_operation', apiKey, - params: { name: 'execute_operation', arguments: { operationId: 'listProjects' } }, + params: { name: 'execute_read_operation', arguments: { operationId: 'listProjects' } }, })); const body = await response.json(); diff --git a/src/server/mcp/mcp-handler.unit.spec.ts b/src/server/mcp/mcp-handler.unit.spec.ts index 12a32a59..d890a0a1 100644 --- a/src/server/mcp/mcp-handler.unit.spec.ts +++ b/src/server/mcp/mcp-handler.unit.spec.ts @@ -119,12 +119,12 @@ async function callTool(handler: QuickStackMcpHandler, name: string, args: Recor } describe('QuickStack MCP handler', () => { - it('lists exactly the two meta tools', async () => { + it('lists the search, read and write tools', async () => { const { status, body } = await listTools(buildHandler()); expect(status).toBe(200); const names = body.result.tools.map((tool: { name: string }) => tool.name).sort(); - expect(names).toEqual(['execute_operation', 'search_operations']); + expect(names).toEqual(['execute_operation', 'execute_read_operation', 'search_operations']); }); it('rejects a request with an invalid REST API Key', async () => { @@ -189,15 +189,25 @@ describe('QuickStack MCP handler', () => { expect(listProjects?.supported).toBe(true); }); + it('reports readOnly and the matching tool for each operation', async () => { + const { body } = await callTool(buildHandler(), 'search_operations', { detail: 'full' }); + 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' }); + }); + it('executes a read operation and returns the REST payload', async () => { - const { body } = await callTool(buildHandler(), 'execute_operation', { operationId: 'listProjects' }); + const { body } = await callTool(buildHandler(), '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_operation', { + const { body } = await callTool(buildHandler(), 'execute_read_operation', { operationId: 'getProject', pathParams: { id: 'p-42' }, query: { search: 'needle' }, @@ -216,12 +226,26 @@ describe('QuickStack MCP handler', () => { }); it('forwards the bearer credential to the REST route', async () => { - const { body } = await callTool(buildHandler(), 'execute_operation', { operationId: 'listProjects' }); + const { body } = await callTool(buildHandler(), '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' } }); + + expect(body.result.isError).toBe(true); + expect(body.result.content[0].text).toContain('execute_operation'); + }); + + it('rejects a read-only operation on the write tool', async () => { + const { body } = await callTool(buildHandler(), 'execute_operation', { operationId: 'listProjects' }); + + expect(body.result.isError).toBe(true); + expect(body.result.content[0].text).toContain('execute_read_operation'); + }); + it('returns a tool error for an unknown operation id', async () => { const { body } = await callTool(buildHandler(), 'execute_operation', { operationId: 'doesNotExist' }); @@ -230,7 +254,7 @@ describe('QuickStack MCP handler', () => { }); it('returns a tool error for an unsupported operation', async () => { - const { body } = await callTool(buildHandler(), 'execute_operation', { operationId: 'streamLogs' }); + const { body } = await callTool(buildHandler(), 'execute_read_operation', { operationId: 'streamLogs' }); expect(body.result.isError).toBe(true); }); @@ -242,7 +266,7 @@ describe('QuickStack MCP handler', () => { resolveAuthInfo: async () => ({ token: 'wrong', clientId: 'user-1', scopes: [] }), }); - const { body } = await callTool(handler, 'execute_operation', { operationId: 'listProjects' }); + const { body } = await callTool(handler, 'execute_read_operation', { operationId: 'listProjects' }); expect(body.result.isError).toBe(true); expect(body.result.content[0].text).toContain('401'); diff --git a/src/server/mcp/mcp-server.factory.ts b/src/server/mcp/mcp-server.factory.ts index 95730ae0..1c0b54cd 100644 --- a/src/server/mcp/mcp-server.factory.ts +++ b/src/server/mcp/mcp-server.factory.ts @@ -4,6 +4,9 @@ import { z } from 'zod'; import operationExecutor from './operation-executor'; import type { McpSourceApp, OperationDescriptor } from './operation-registry'; +const READ_TOOL = 'execute_read_operation'; +const WRITE_TOOL = 'execute_operation'; + 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.'), @@ -17,6 +20,8 @@ const operationSummarySchema = z.object({ description: z.string().optional(), tags: z.array(z.string()), supported: z.boolean(), + readOnly: z.boolean(), + tool: z.enum([READ_TOOL, WRITE_TOOL]), }); const operationFullSchema = operationSummarySchema.extend({ @@ -31,10 +36,13 @@ const searchOutputSchema = z.object({ operations: z.array(operationFullSchema), }); -const executeInputSchema = z.object({ +const readInputSchema = z.object({ operationId: z.string().describe('Operation id returned by search_operations.'), 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.'), +}); + +const writeInputSchema = readInputSchema.extend({ body: z.unknown().optional().describe('JSON request body for write operations.'), }); @@ -46,20 +54,33 @@ const executeOutputSchema = z.object({ 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,', - 'then call execute_operation. Operations mirror the QuickStack REST API.', + 'Use this tool first to discover the operation id for the action 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.', ].join(' '); -const EXECUTE_TOOL_DESCRIPTION = [ - 'Execute a QuickStack operation by its operation id.', - 'Call search_operations first to find a valid operation id and its schema.', - 'Map your arguments to pathParams, query and body according to the operation schema:', - 'pathParams fills :placeholders in the path, query becomes the query string, and', - 'body is the JSON request body for write operations.', - 'Responses follow the QuickStack REST API. Errors are returned as tool errors.', +const READ_TOOL_DESCRIPTION = [ + 'Execute a read-only QuickStack operation.', + 'Call search_operations 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.', +].join(' '); + +type OperationArguments = { + operationId: string; + pathParams?: Record; + query?: Record; + body?: unknown; +}; + export type McpServerBuildOptions = { app: McpSourceApp; operations: OperationDescriptor[]; @@ -71,7 +92,8 @@ class McpServerFactory { build(options: McpServerBuildOptions): McpServer { const server = new McpServer({ name: 'quickstack', version: '1.0.0' }); this.registerSearchOperation(server, options.operations); - this.registerExecuteOperation(server, options); + this.registerReadOperation(server, options); + this.registerWriteOperation(server, options); return server; } @@ -99,54 +121,82 @@ class McpServerFactory { ); } - private registerExecuteOperation(server: McpServer, options: McpServerBuildOptions): void { - const { app, operations, authInfo } = options; + private registerReadOperation(server: McpServer, options: McpServerBuildOptions): void { + server.registerTool( + READ_TOOL, + { + title: 'Execute read-only QuickStack operation', + description: READ_TOOL_DESCRIPTION, + inputSchema: readInputSchema, + outputSchema: executeOutputSchema, + annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: false }, + }, + async ({ operationId, pathParams, query }) => this.runOperation( + options, + { operationId, pathParams, query }, + true + ) + ); + } + private registerWriteOperation(server: McpServer, options: McpServerBuildOptions): void { server.registerTool( - 'execute_operation', + WRITE_TOOL, { - title: 'Execute QuickStack operation', - description: EXECUTE_TOOL_DESCRIPTION, - inputSchema: executeInputSchema, + title: 'Execute mutating QuickStack operation', + description: WRITE_TOOL_DESCRIPTION, + inputSchema: writeInputSchema, outputSchema: executeOutputSchema, annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false }, }, - async ({ operationId, pathParams, query, body }) => { - const operation = operations.find((candidate) => candidate.operationId === operationId); - if (!operation) { - return this.errorResult(`Unknown operation "${operationId}". Call search_operations to find valid operation ids.`); - } - if (!operation.supported) { - return this.errorResult(`Operation "${operationId}" is not available over MCP. ${operation.unsupportedReason ?? ''}`.trim()); - } - - let execution; - try { - execution = await operationExecutor.execute({ - app, - operation, - pathParams: pathParams ?? {}, - query: query ?? {}, - body, - bearerToken: authInfo?.token, - }); - } catch (error) { - return this.errorResult(error instanceof Error ? error.message : 'Operation failed.'); - } - - if (execution.status >= 200 && execution.status < 300) { - const result = execution.result ?? null; - return { - content: [{ type: 'text' as const, text: JSON.stringify(result) }], - structuredContent: { status: execution.status, result }, - }; - } - - return this.errorResult(this.describeFailure(execution.status, execution.result)); - } + async ({ operationId, pathParams, query, body }) => this.runOperation( + options, + { operationId, pathParams, query, body }, + false + ) ); } + private async runOperation(options: McpServerBuildOptions, args: OperationArguments, expectReadOnly: boolean) { + const { app, operations, authInfo } = options; + 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.`); + } + if (!operation.supported) { + return this.errorResult(`Operation "${args.operationId}" is not available over MCP. ${operation.unsupportedReason ?? ''}`.trim()); + } + if (operation.readOnly !== expectReadOnly) { + const tool = operation.readOnly ? READ_TOOL : WRITE_TOOL; + return this.errorResult(`Operation "${args.operationId}" is ${operation.readOnly ? 'read-only' : 'mutating'}. Call ${tool} instead.`); + } + + let execution; + try { + execution = await operationExecutor.execute({ + app, + operation, + pathParams: args.pathParams ?? {}, + query: args.query ?? {}, + body: args.body, + bearerToken: authInfo?.token, + }); + } catch (error) { + return this.errorResult(error instanceof Error ? error.message : 'Operation failed.'); + } + + if (execution.status >= 200 && execution.status < 300) { + const result = execution.result ?? null; + return { + content: [{ type: 'text' as const, text: JSON.stringify(result) }], + structuredContent: { status: execution.status, result }, + }; + } + + return this.errorResult(this.describeFailure(execution.status, execution.result)); + } + private serializeOperation(operation: OperationDescriptor, detail: 'name' | 'summary' | 'full') { const summary = { operationId: operation.operationId, @@ -154,6 +204,8 @@ class McpServerFactory { path: operation.path, tags: operation.tags, supported: operation.supported, + readOnly: operation.readOnly, + tool: operation.readOnly ? READ_TOOL : WRITE_TOOL, ...(detail === 'name' ? {} : { summary: operation.summary, description: operation.description, @@ -183,7 +235,7 @@ class McpServerFactory { operation.summary, operation.description, ...operation.tags, - ].filter((value): value is string => !!value); + ].filter((value): value is string => Boolean(value)); return haystack.some((value) => value.toLowerCase().includes(term)); } diff --git a/src/server/mcp/operation-registry.ts b/src/server/mcp/operation-registry.ts index 435c8746..d917c353 100644 --- a/src/server/mcp/operation-registry.ts +++ b/src/server/mcp/operation-registry.ts @@ -18,8 +18,13 @@ export type OperationDescriptor = { responseSchema?: Record; supported: boolean; unsupportedReason?: string; + readOnly: boolean; }; +function isReadOnlyMethod(method: string): boolean { + return method === 'GET' || method === 'HEAD'; +} + type ElysiaRoute = { method: string; path: string; @@ -80,6 +85,7 @@ class OperationRegistry { responseSchema: this.toJsonSchema(responseSchema), supported, unsupportedReason, + readOnly: isReadOnlyMethod(route.method), }); } From 8c9a9fd19d25dddd04a0781ded4fa22724c6a58e Mon Sep 17 00:00:00 2001 From: aperolschpritz Date: Sat, 3 Oct 2026 12:10:56 +0000 Subject: [PATCH 8/9] feat: gate MCP behind canary channel and warn about secret exposure --- src/app/api/mcp/route.ts | 8 +++-- src/app/api/mcp/route.unit.spec.ts | 29 ++++++++++++++++--- .../settings/developer/mcp/mcp-settings.tsx | 20 +++++++++++-- src/app/settings/developer/mcp/page.tsx | 5 ++++ src/app/sidebar-client.tsx | 10 +++++-- src/app/sidebar.tsx | 4 ++- src/server/services/param.service.ts | 2 +- src/shared/utils/settings-navigation.ts | 3 +- 8 files changed, 68 insertions(+), 13 deletions(-) diff --git a/src/app/api/mcp/route.ts b/src/app/api/mcp/route.ts index 1f8e7418..32b66873 100644 --- a/src/app/api/mcp/route.ts +++ b/src/app/api/mcp/route.ts @@ -11,8 +11,12 @@ const mcpHandler = createQuickStackMcpHandler({ }); async function handleRequest(request: Request): Promise { - const enabled = await paramService.getBoolean(ParamService.MCP_SERVER_ENABLED); - if (!enabled) { + const [canaryEnabled, enabled] = await Promise.all([ + paramService.getBoolean(ParamService.USE_CANARY_CHANNEL), + paramService.getBoolean(ParamService.MCP_SERVER_ENABLED), + ]); + + if (!canaryEnabled || !enabled) { return new Response( JSON.stringify({ jsonrpc: '2.0', id: null, error: { code: -32002, message: 'MCP server is disabled.' } }), { diff --git a/src/app/api/mcp/route.unit.spec.ts b/src/app/api/mcp/route.unit.spec.ts index b3d8a014..67b7c389 100644 --- a/src/app/api/mcp/route.unit.spec.ts +++ b/src/app/api/mcp/route.unit.spec.ts @@ -5,7 +5,7 @@ const routeMocks = vi.hoisted(() => ({ vi.mock('@/server/services/param.service', () => ({ default: { getBoolean: routeMocks.getBoolean }, - ParamService: { MCP_SERVER_ENABLED: 'mcpServerEnabled' }, + ParamService: { MCP_SERVER_ENABLED: 'mcpServerEnabled', USE_CANARY_CHANNEL: 'useCanaryChannel' }, })); vi.mock('@/server/api/v1/api-index', () => ({ v1Api: {} })); @@ -18,13 +18,34 @@ vi.mock('@/server/mcp/mcp-handler', () => ({ import { POST } from './route'; +function setParams(values: { canary: boolean; mcp: boolean }) { + routeMocks.getBoolean.mockImplementation(async (name: string) => { + if (name === 'useCanaryChannel') { + return values.canary; + } + if (name === 'mcpServerEnabled') { + return values.mcp; + } + return undefined; + }); +} + describe('MCP route enablement', () => { beforeEach(() => { vi.resetAllMocks(); }); it('rejects requests when the MCP server is disabled', async () => { - routeMocks.getBoolean.mockResolvedValue(false); + setParams({ canary: true, mcp: false }); + + const response = await POST(new Request('http://localhost/api/mcp', { method: 'POST' })); + + expect(response.status).toBe(404); + expect(routeMocks.mcpFetch).not.toHaveBeenCalled(); + }); + + it('rejects requests when the canary channel is disabled', async () => { + setParams({ canary: false, mcp: true }); const response = await POST(new Request('http://localhost/api/mcp', { method: 'POST' })); @@ -32,8 +53,8 @@ describe('MCP route enablement', () => { expect(routeMocks.mcpFetch).not.toHaveBeenCalled(); }); - it('delegates to the MCP handler when the MCP server is enabled', async () => { - routeMocks.getBoolean.mockResolvedValue(true); + it('delegates to the MCP handler when canary and the MCP server are enabled', async () => { + setParams({ canary: true, mcp: true }); routeMocks.mcpFetch.mockResolvedValue(new Response('handled', { status: 200 })); const response = await POST(new Request('http://localhost/api/mcp', { method: 'POST' })); diff --git a/src/app/settings/developer/mcp/mcp-settings.tsx b/src/app/settings/developer/mcp/mcp-settings.tsx index 1ec95274..ebeb9b65 100644 --- a/src/app/settings/developer/mcp/mcp-settings.tsx +++ b/src/app/settings/developer/mcp/mcp-settings.tsx @@ -2,7 +2,8 @@ import { useEffect, useState, type ReactNode } from 'react'; import { toast } from 'sonner'; -import { Copy } from 'lucide-react'; +import { Copy, 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 { Label } from '@/components/ui/label'; @@ -112,9 +113,24 @@ Authorization: Bearer return (
+ + + Canary feature: secrets can flow through MCP + + The MCP server is a canary feature. Tool arguments and results are passed to the AI host and model. + 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. Agent Git SSH keys are returned encrypted. Only connect trusted clients and use a REST API + Key from a user with the minimum required permissions. + + + - MCP Server + + MCP Server + Canary + Expose QuickStack as a remote Model Context Protocol server. AI clients such as Claude Code, Codex and GitHub Copilot can then act on QuickStack through the REST API using a REST API Key. diff --git a/src/app/settings/developer/mcp/page.tsx b/src/app/settings/developer/mcp/page.tsx index ba715a7f..8107438a 100644 --- a/src/app/settings/developer/mcp/page.tsx +++ b/src/app/settings/developer/mcp/page.tsx @@ -1,3 +1,4 @@ +import { notFound } from "next/navigation" import paramService, { ParamService } from "@/server/services/param.service" import { getAdminUserSession } from "@/server/utils/action-wrapper.utils" import { ServerSettingsPage } from "../../server-settings-page" @@ -5,6 +6,10 @@ import McpSettings from "./mcp-settings" export default async function McpSettingsPage() { await getAdminUserSession() + const canaryEnabled = await paramService.getBoolean(ParamService.USE_CANARY_CHANNEL) + if (!canaryEnabled) { + notFound() + } const mcpEnabled = await paramService.getBoolean(ParamService.MCP_SERVER_ENABLED) return diff --git a/src/app/sidebar-client.tsx b/src/app/sidebar-client.tsx index be88b768..6eb15147 100644 --- a/src/app/sidebar-client.tsx +++ b/src/app/sidebar-client.tsx @@ -38,12 +38,14 @@ function SettingsNavigationContent({ path, visibleSettingsGroups, isAdmin, + canaryEnabled, newVersionInfo, onNavigate, }: { path: string visibleSettingsGroups: SettingsNavigationGroup[] isAdmin: boolean + canaryEnabled: boolean newVersionInfo?: QuickStackReleaseInfo onNavigate?: () => void }) { @@ -87,7 +89,7 @@ function SettingsNavigationContent({ Developer - {developerSettingsNavigation.map((item) => { + {developerSettingsNavigation.filter((item) => !item.canaryOnly || canaryEnabled).map((item) => { const Icon = item.icon return @@ -106,12 +108,14 @@ export function SidebarCient({ projects, session, newVersionInfo, - agentsAvailable + agentsAvailable, + canaryEnabled }: { projects: ProjectNavigationModel[]; session: UserSession; newVersionInfo?: QuickStackReleaseInfo; agentsAvailable: boolean; + canaryEnabled: boolean; }) { const path = usePathname(); @@ -372,6 +376,7 @@ export function SidebarCient({ path={path} visibleSettingsGroups={visibleSettingsGroups} isAdmin={isAdmin} + canaryEnabled={canaryEnabled} newVersionInfo={newVersionInfo} onNavigate={() => setSettingsNavigationOpen(false)} /> @@ -387,6 +392,7 @@ export function SidebarCient({ path={path} visibleSettingsGroups={visibleSettingsGroups} isAdmin={isAdmin} + canaryEnabled={canaryEnabled} newVersionInfo={newVersionInfo} /> diff --git a/src/app/sidebar.tsx b/src/app/sidebar.tsx index 974dd179..9a11a913 100644 --- a/src/app/sidebar.tsx +++ b/src/app/sidebar.tsx @@ -4,6 +4,7 @@ import { SidebarCient } from "./sidebar-client" import { UserGroupUtils } from "@/shared/utils/role.utils"; import quickStackUpdateService from "@/server/services/qs-update.service"; import agentSandboxAddonService from "@/server/services/addons/agent-sandbox-addon.service"; +import paramService, { ParamService } from "@/server/services/param.service"; export async function AppSidebar() { @@ -17,6 +18,7 @@ export async function AppSidebar() { const newVersionInfo = await quickStackUpdateService.getNewVersionInfo(); const agentSandboxStatus = await agentSandboxAddonService.getStatus(); const agentsAvailable = agentSandboxStatus.status === 'ready'; + const canaryEnabled = (await paramService.getBoolean(ParamService.USE_CANARY_CHANNEL)) ?? false; const relevantProjectsForUser = projects.filter((project) => UserGroupUtils.sessionHasReadAccessToProject(session, project.id)); for (const project of relevantProjectsForUser) { @@ -24,5 +26,5 @@ export async function AppSidebar() { project.agents = project.agents.filter((agent) => UserGroupUtils.sessionHasReadAccessForProjectWorkload(session, agent.id)); } - return + return } diff --git a/src/server/services/param.service.ts b/src/server/services/param.service.ts index 449b6833..a6bb49e0 100644 --- a/src/server/services/param.service.ts +++ b/src/server/services/param.service.ts @@ -37,7 +37,7 @@ export class ParamService { this.getOrCreate(ParamService.QS_SYSTEM_BACKUP_LOCATION, Constants.QS_SYSTEM_BACKUP_DEACTIVATED, revalidateParam), 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, 'true', revalidateParam), + this.getOrCreate(ParamService.MCP_SERVER_ENABLED, 'false', revalidateParam), ]); return { instanceId, registryLocation }; diff --git a/src/shared/utils/settings-navigation.ts b/src/shared/utils/settings-navigation.ts index 4f319b10..5d72b77d 100644 --- a/src/shared/utils/settings-navigation.ts +++ b/src/shared/utils/settings-navigation.ts @@ -20,6 +20,7 @@ export type SettingsNavigationItem = { href: string icon: LucideIcon adminOnly?: boolean + canaryOnly?: boolean } export type SettingsNavigationGroup = { @@ -63,7 +64,7 @@ export const serverSettingsNavigation: ServerSettingsNavigationItem[] = [ export const developerSettingsNavigation: SettingsNavigationItem[] = [ { title: "REST API", href: "/settings/developer/rest-api", icon: ChevronsLeftRightEllipsis, adminOnly: true }, - { title: "MCP Server", href: "/settings/developer/mcp", icon: Bot, adminOnly: true }, + { title: "MCP Server", href: "/settings/developer/mcp", icon: Bot, adminOnly: true, canaryOnly: true }, ] export function serverSettingsHref(tab: string) { From 7e6edd38904f398adc3e048ae3217800df9de5b8 Mon Sep 17 00:00:00 2001 From: biersoeckli Date: Sat, 3 Oct 2026 12:30:39 +0000 Subject: [PATCH 9/9] feat: mark writeAgentSandboxFile it as excluded from MCP --- .../settings/developer/mcp/mcp-settings.tsx | 6 +++--- src/server/mcp/mcp-handler.unit.spec.ts | 21 +++++++++++++++++-- src/server/mcp/operation-registry.ts | 9 +++++++- 3 files changed, 30 insertions(+), 6 deletions(-) diff --git a/src/app/settings/developer/mcp/mcp-settings.tsx b/src/app/settings/developer/mcp/mcp-settings.tsx index ebeb9b65..a5cc019c 100644 --- a/src/app/settings/developer/mcp/mcp-settings.tsx +++ b/src/app/settings/developer/mcp/mcp-settings.tsx @@ -117,10 +117,10 @@ Authorization: Bearer Canary feature: secrets can flow through MCP - The MCP server is a canary feature. Tool arguments and results are passed to the AI host and model. - Read operations can return secrets in plaintext, including App environment variables, build arguments, + 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. Agent Git SSH keys are returned encrypted. Only connect trusted clients and use a REST API + same values. Only connect trusted clients and use a REST API Key from a user with the minimum required permissions. diff --git a/src/server/mcp/mcp-handler.unit.spec.ts b/src/server/mcp/mcp-handler.unit.spec.ts index d890a0a1..0ad951ea 100644 --- a/src/server/mcp/mcp-handler.unit.spec.ts +++ b/src/server/mcp/mcp-handler.unit.spec.ts @@ -39,6 +39,14 @@ function buildFixtureApp() { response: { 200: z.object({ id: z.string(), name: z.string() }), 401: unauthorizedSchema }, detail: { operationId: 'saveProject', summary: 'Save project', tags: ['Projects'] }, }) + .put('/agents/:agentId/sandboxes/:sandboxName/files/write', ({ body, authenticated }) => { + if (!authenticated) return unauthorized(); + return { path: body.path }; + }, { + body: z.object({ path: z.string() }), + response: { 200: z.object({ path: z.string() }), 401: unauthorizedSchema }, + detail: { operationId: 'writeAgentSandboxFile', summary: 'Write sandbox file', tags: ['Agent Sandboxes'] }, + }) .post('/upload', () => new Response(JSON.stringify({ ok: true }), { headers: { 'content-type': 'application/json' } }), { parse: 'none', response: { 200: z.object({ ok: z.boolean() }) }, @@ -176,14 +184,23 @@ describe('QuickStack MCP handler', () => { expect(fullOperation.querySchema).toBeDefined(); }); - it('marks streaming and manually parsed operations as unsupported', async () => { + it('marks excluded, streaming, and manually parsed operations as unsupported', async () => { const { body } = await callTool(buildHandler(), 'search_operations', { detail: 'full' }); - const operations = body.result.structuredContent.operations as Array<{ operationId: string; supported: boolean }>; + const operations = body.result.structuredContent.operations as Array<{ + operationId: string; + supported: boolean; + unsupportedReason?: string; + }>; + const writeAgentSandboxFile = operations.find((operation) => operation.operationId === 'writeAgentSandboxFile'); const streamLogs = operations.find((operation) => operation.operationId === 'streamLogs'); 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(streamLogs?.supported).toBe(false); expect(uploadFile?.supported).toBe(false); expect(listProjects?.supported).toBe(true); diff --git a/src/server/mcp/operation-registry.ts b/src/server/mcp/operation-registry.ts index d917c353..c8f8927f 100644 --- a/src/server/mcp/operation-registry.ts +++ b/src/server/mcp/operation-registry.ts @@ -45,6 +45,10 @@ type ElysiaRoute = { class OperationRegistry { + private operationExclusionList = new Set([ + 'writeAgentSandboxFile', + ]); + build(app: McpSourceApp): OperationDescriptor[] { const routes = (app.routes ?? []) as ElysiaRoute[]; const seen = new Set(); @@ -64,7 +68,10 @@ class OperationRegistry { let supported = true; let unsupportedReason: string | undefined; - if (!responseSchema) { + if (this.operationExclusionList.has(operationId)) { + supported = false; + unsupportedReason = 'Operation is excluded from MCP.'; + } else if (!responseSchema) { supported = false; unsupportedReason = 'Operation does not declare a JSON response.'; } else if (manualBodyParsing) {