diff --git a/apps/web/app/api/mcp/route.test.ts b/apps/web/app/api/mcp/route.test.ts new file mode 100644 index 000000000000..0838b985d80a --- /dev/null +++ b/apps/web/app/api/mcp/route.test.ts @@ -0,0 +1,221 @@ +import { ApiKeyPermission } from "@prisma/client"; +import { NextRequest } from "next/server"; +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { successListResponse } from "@/app/api/v3/lib/response"; +import { listV3Surveys } from "@/app/api/v3/surveys/lib/operations"; +import { DEFAULT_REQUEST_BODY_LIMIT_BYTES } from "@/app/lib/api/request-body"; +import { authenticateApiKeyFromHeaders } from "@/modules/api/lib/api-key-auth"; +import { applyRateLimit } from "@/modules/core/rate-limit/helpers"; +import { POST } from "./route"; + +vi.mock("@/modules/api/lib/api-key-auth", () => ({ + authenticateApiKeyFromHeaders: vi.fn(), +})); + +vi.mock("@/modules/core/rate-limit/helpers", () => ({ + applyRateLimit: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@/app/api/v3/surveys/lib/operations", () => ({ + createV3SurveyResponse: vi.fn(), + deleteV3Survey: vi.fn(), + getV3Survey: vi.fn(), + listV3Surveys: vi.fn(), + patchV3SurveyResponse: vi.fn(), + validateV3Survey: vi.fn(), +})); + +vi.mock("@/app/api/v3/lib/audit", () => ({ + buildV3AuditLog: vi.fn(), + queueV3AuditLog: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@formbricks/logger", () => ({ + logger: { + withContext: vi.fn(() => ({ + error: vi.fn(), + warn: vi.fn(), + })), + }, +})); + +const apiKeyAuth = { + type: "apiKey" as const, + apiKeyId: "key_1", + organizationId: "org_1", + organizationAccess: { + accessControl: { read: true, write: true }, + }, + workspacePermissions: [ + { + workspaceId: "clxx1234567890123456789012", + workspaceName: "Workspace", + permission: ApiKeyPermission.write, + }, + ], +}; + +function createMcpRequest(body: Record, headers: Record = {}): NextRequest { + return new NextRequest("http://localhost/api/mcp", { + method: "POST", + headers: { + accept: "application/json, text/event-stream", + "content-type": "application/json", + "mcp-protocol-version": "2025-06-18", + "x-api-key": "fbk_test", + ...headers, + }, + body: JSON.stringify(body), + }); +} + +async function readMcpResponse(response: Response): Promise> { + const text = await response.text(); + const dataLine = text + .split("\n") + .map((line) => line.trim()) + .find((line) => line.startsWith("data:")); + + return JSON.parse(dataLine ? dataLine.slice("data:".length).trim() : text); +} + +describe("POST /api/mcp", () => { + beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(authenticateApiKeyFromHeaders).mockResolvedValue(apiKeyAuth); + vi.mocked(applyRateLimit).mockResolvedValue(undefined); + vi.mocked(listV3Surveys).mockResolvedValue( + successListResponse([], { limit: 20, nextCursor: null, totalCount: 0 }, { requestId: "req_mcp" }) + ); + }); + + test("returns 401 before MCP handling when authentication fails", async () => { + vi.mocked(authenticateApiKeyFromHeaders).mockResolvedValue(null); + + const response = await POST( + createMcpRequest({ + jsonrpc: "2.0", + id: 1, + method: "tools/list", + params: {}, + }) + ); + + expect(response.status).toBe(401); + expect(response.headers.get("Content-Type")).toBe("application/problem+json"); + }); + + test("returns 413 before MCP handling when content-length exceeds the v3 body limit", async () => { + const response = await POST( + createMcpRequest( + { + jsonrpc: "2.0", + id: 1, + method: "tools/list", + params: {}, + }, + { + "content-length": String(DEFAULT_REQUEST_BODY_LIMIT_BYTES + 1), + "x-request-id": "req_large", + } + ) + ); + + expect(response.status).toBe(413); + expect(response.headers.get("X-Request-Id")).toBe("req_large"); + expect(authenticateApiKeyFromHeaders).not.toHaveBeenCalled(); + }); + + test("lists MCP tools for a valid API key", async () => { + const response = await POST( + createMcpRequest( + { + jsonrpc: "2.0", + id: 1, + method: "tools/list", + params: {}, + }, + { + "x-request-id": "req_tools", + } + ) + ); + + expect(response.status).toBe(200); + expect(response.headers.get("X-Request-Id")).toBe("req_tools"); + const message = await readMcpResponse(response); + expect(message.result.tools.map((tool: { name: string }) => tool.name)).toEqual([ + "list_surveys", + "get_survey", + "create_survey", + "validate_survey", + "patch_survey", + "delete_survey", + ]); + expect(message.result.tools.find((tool: { name: string }) => tool.name === "list_surveys")).toMatchObject( + { + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + }, + } + ); + expect(message.result.tools.find((tool: { name: string }) => tool.name === "patch_survey")).toMatchObject( + { + annotations: { + readOnlyHint: false, + destructiveHint: true, + idempotentHint: false, + }, + } + ); + expect( + message.result.tools.find((tool: { name: string }) => tool.name === "delete_survey") + ).toMatchObject({ + annotations: { + readOnlyHint: false, + destructiveHint: true, + idempotentHint: false, + }, + }); + }); + + test("calls list_surveys through the MCP route", async () => { + const response = await POST( + createMcpRequest( + { + jsonrpc: "2.0", + id: 2, + method: "tools/call", + params: { + name: "list_surveys", + arguments: { + workspaceId: "clxx1234567890123456789012", + limit: 20, + includeTotalCount: true, + }, + }, + }, + { + "x-request-id": "req_mcp", + } + ) + ); + + expect(response.status).toBe(200); + const message = await readMcpResponse(response); + expect(message.result.structuredContent).toEqual({ + data: [], + meta: { limit: 20, nextCursor: null, totalCount: 0 }, + requestId: "req_mcp", + }); + expect(listV3Surveys).toHaveBeenCalledWith( + expect.objectContaining({ + authentication: apiKeyAuth, + requestId: "req_mcp", + instance: "/api/mcp", + }) + ); + }); +}); diff --git a/apps/web/app/api/mcp/route.ts b/apps/web/app/api/mcp/route.ts new file mode 100644 index 000000000000..d04faee9f2e5 --- /dev/null +++ b/apps/web/app/api/mcp/route.ts @@ -0,0 +1,44 @@ +import { type NextRequest } from "next/server"; +import { DEFAULT_REQUEST_BODY_LIMIT_BYTES } from "@/app/lib/api/request-body"; +import { problemPayloadTooLarge } from "@/app/api/v3/lib/response"; +import { handleAuthenticatedMcpRequest } from "@/modules/mcp/auth"; +import { mcpHandler } from "@/modules/mcp/server"; + +export const runtime = "nodejs"; +export const fetchCache = "force-no-store"; + +function getRequestId(request: NextRequest): string { + return request.headers.get("x-request-id") ?? crypto.randomUUID(); +} + +function getContentLength(headers: Headers): number | null { + const contentLength = headers.get("content-length"); + if (!contentLength) { + return null; + } + + const parsedContentLength = Number(contentLength); + return Number.isSafeInteger(parsedContentLength) && parsedContentLength >= 0 ? parsedContentLength : null; +} + +function validateMcpBodySize(request: NextRequest): Response | null { + const contentLength = getContentLength(request.headers); + if (contentLength === null || contentLength <= DEFAULT_REQUEST_BODY_LIMIT_BYTES) { + return null; + } + + return problemPayloadTooLarge( + getRequestId(request), + `Request body must not exceed ${DEFAULT_REQUEST_BODY_LIMIT_BYTES} bytes`, + request.nextUrl.pathname + ); +} + +export async function POST(request: NextRequest): Promise { + const bodySizeResponse = validateMcpBodySize(request); + if (bodySizeResponse) { + return bodySizeResponse; + } + + return await handleAuthenticatedMcpRequest(request, mcpHandler); +} diff --git a/apps/web/app/api/v3/lib/api-wrapper.ts b/apps/web/app/api/v3/lib/api-wrapper.ts index 18c07787afe4..96c75f6af012 100644 --- a/apps/web/app/api/v3/lib/api-wrapper.ts +++ b/apps/web/app/api/v3/lib/api-wrapper.ts @@ -5,14 +5,13 @@ import { logger } from "@formbricks/logger"; import { TooManyRequestsError } from "@formbricks/types/errors"; import { authenticateRequest } from "@/app/api/v1/auth"; import { RequestBodyTooLargeError, parseJsonBodyWithLimit } from "@/app/lib/api/request-body"; -import { buildAuditLogBaseObject } from "@/app/lib/api/with-api-logging"; import { getApiKeyFromHeaders } from "@/modules/api/lib/api-key-auth"; import { authOptions } from "@/modules/auth/lib/authOptions"; import { applyRateLimit } from "@/modules/core/rate-limit/helpers"; import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs"; import type { TRateLimitConfig } from "@/modules/core/rate-limit/types/rate-limit"; -import { queueAuditEvent } from "@/modules/ee/audit-logs/lib/handler"; import { TAuditAction, TAuditTarget } from "@/modules/ee/audit-logs/types/audit-log"; +import { buildV3AuditLog, queueV3AuditLog } from "./audit"; import { type InvalidParam, isInvalidParamCode, @@ -334,49 +333,6 @@ async function applyV3RateLimitOrRespond(params: { return null; } -function buildV3AuditLog( - authentication: TV3Authentication, - action?: TAuditAction, - targetType?: TAuditTarget, - apiUrl?: string -): TV3AuditLog | undefined { - if (!authentication || !action || !targetType || !apiUrl) { - return undefined; - } - - const auditLog = buildAuditLogBaseObject(action, targetType, apiUrl); - - if ("user" in authentication && authentication.user?.id) { - auditLog.userId = authentication.user.id; - auditLog.userType = "user"; - } else if ("apiKeyId" in authentication) { - auditLog.userId = authentication.apiKeyId; - auditLog.userType = "api"; - auditLog.organizationId = authentication.organizationId; - } - - return auditLog; -} - -async function queueV3AuditLog( - auditLog: TV3AuditLog | undefined, - requestId: string, - log: ReturnType -): Promise { - if (!auditLog) { - return; - } - - try { - await queueAuditEvent({ - ...auditLog, - ...(auditLog.status === "failure" ? { eventId: auditLog.eventId ?? requestId } : {}), - }); - } catch (error) { - log.error({ error }, "Failed to queue V3 audit event"); - } -} - export const withV3ApiWrapper = ( params: TWithV3ApiWrapperParams ): ((req: NextRequest, props: TProps) => Promise) => { diff --git a/apps/web/app/api/v3/lib/audit.ts b/apps/web/app/api/v3/lib/audit.ts new file mode 100644 index 000000000000..6a917032b3c2 --- /dev/null +++ b/apps/web/app/api/v3/lib/audit.ts @@ -0,0 +1,48 @@ +import { logger } from "@formbricks/logger"; +import { buildAuditLogBaseObject } from "@/app/lib/api/with-api-logging"; +import { queueAuditEvent } from "@/modules/ee/audit-logs/lib/handler"; +import { TAuditAction, TAuditTarget } from "@/modules/ee/audit-logs/types/audit-log"; +import type { TV3AuditLog, TV3Authentication } from "./types"; + +export function buildV3AuditLog( + authentication: TV3Authentication, + action?: TAuditAction, + targetType?: TAuditTarget, + apiUrl?: string +): TV3AuditLog | undefined { + if (!authentication || !action || !targetType || !apiUrl) { + return undefined; + } + + const auditLog = buildAuditLogBaseObject(action, targetType, apiUrl); + + if ("user" in authentication && authentication.user?.id) { + auditLog.userId = authentication.user.id; + auditLog.userType = "user"; + } else if ("apiKeyId" in authentication) { + auditLog.userId = authentication.apiKeyId; + auditLog.userType = "api"; + auditLog.organizationId = authentication.organizationId; + } + + return auditLog; +} + +export async function queueV3AuditLog( + auditLog: TV3AuditLog | undefined, + requestId: string, + log: ReturnType +): Promise { + if (!auditLog) { + return; + } + + try { + await queueAuditEvent({ + ...auditLog, + ...(auditLog.status === "failure" ? { eventId: auditLog.eventId ?? requestId } : {}), + }); + } catch (error) { + log.error({ error }, "Failed to queue V3 audit event"); + } +} diff --git a/apps/web/app/api/v3/surveys/[surveyId]/route.ts b/apps/web/app/api/v3/surveys/[surveyId]/route.ts index 2bc8cf1869e8..bb3317e27c76 100644 --- a/apps/web/app/api/v3/surveys/[surveyId]/route.ts +++ b/apps/web/app/api/v3/surveys/[surveyId]/route.ts @@ -1,26 +1,8 @@ import { z } from "zod"; -import { logger } from "@formbricks/logger"; -import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors"; import { withV3ApiWrapper } from "@/app/api/v3/lib/api-wrapper"; -import { - noContentResponse, - problemBadRequest, - problemForbidden, - problemInternalError, - successResponse, -} from "@/app/api/v3/lib/response"; -import { - V3SurveyLanguageError, - V3SurveyUnsupportedShapeError, - serializeV3SurveyResource, -} from "@/app/api/v3/surveys/serializers"; -import { deleteSurvey } from "@/modules/survey/lib/surveys"; -import { getAuthorizedV3Survey } from "../authorization"; +import { deleteV3Survey, getV3Survey, patchV3SurveyResponse } from "../lib/operations"; import { parseV3SurveyLanguageQuery } from "../language"; -import { patchV3Survey } from "../patch"; -import { V3SurveyReferenceValidationError } from "../reference-validation"; import { ZV3EmptyQuery } from "../schemas"; -import { V3SurveyWritePermissionError } from "../write-permissions"; const surveyParamsSchema = z.object({ surveyId: z.cuid2(), @@ -54,70 +36,13 @@ export const GET = withV3ApiWrapper({ query: surveyQuerySchema, }, handler: async ({ parsedInput, authentication, requestId, instance }) => { - const surveyId = parsedInput.params.surveyId; - const log = logger.withContext({ requestId, surveyId }); - - try { - const { survey, response } = await getAuthorizedV3Survey({ - surveyId, - authentication, - access: "read", - requestId, - instance, - }); - - if (response) { - log.warn({ statusCode: response.status }, "Survey not found or not accessible"); - return response; - } - - try { - return successResponse(serializeV3SurveyResource(survey, { lang: parsedInput.query.lang }), { - requestId, - cache: "private, no-store", - }); - } catch (error) { - if (error instanceof V3SurveyLanguageError) { - log.warn( - { statusCode: 400, detail: error.message, lang: parsedInput.query.lang }, - "Invalid survey language selector" - ); - return problemBadRequest(requestId, error.message, { - instance, - invalid_params: [ - { - name: "lang", - reason: error.message, - ...(error.normalizedCode && { identifier: error.normalizedCode }), - }, - ], - }); - } - - if (error instanceof V3SurveyUnsupportedShapeError) { - log.warn({ statusCode: 400, detail: error.message }, "Unsupported v3 survey shape"); - return problemBadRequest(requestId, error.message, { - instance, - invalid_params: [ - { - name: "survey", - reason: error.message, - }, - ], - }); - } - - throw error; - } - } catch (error) { - if (error instanceof DatabaseError) { - log.error({ error, statusCode: 500 }, "Database error"); - return problemInternalError(requestId, "An unexpected error occurred.", instance); - } - - log.error({ error, statusCode: 500 }, "V3 survey get unexpected error"); - return problemInternalError(requestId, "An unexpected error occurred.", instance); - } + return await getV3Survey({ + surveyId: parsedInput.params.surveyId, + lang: parsedInput.query.lang, + authentication, + requestId, + instance, + }); }, }); @@ -131,93 +56,14 @@ export const PATCH = withV3ApiWrapper({ body: z.unknown(), }, handler: async ({ parsedInput, authentication, requestId, instance, auditLog }) => { - const surveyId = parsedInput.params.surveyId; - const log = logger.withContext({ requestId, surveyId }); - let workspaceId: string | undefined; - - try { - const { survey, authResult, response } = await getAuthorizedV3Survey({ - surveyId, - authentication, - access: "readWrite", - requestId, - instance, - }); - - if (response) { - log.warn({ statusCode: response.status }, "Survey not found or not accessible"); - return response; - } - - workspaceId = survey.workspaceId; - const updatedSurvey = await patchV3Survey( - survey, - parsedInput.body, - requestId, - authResult.organizationId - ); - const resource = serializeV3SurveyResource(updatedSurvey); - - if (auditLog) { - auditLog.targetId = updatedSurvey.id; - auditLog.organizationId = authResult.organizationId; - auditLog.oldObject = serializeV3SurveyResource(survey); - auditLog.newObject = resource; - } - - return successResponse(resource, { - requestId, - cache: "private, no-store", - }); - } catch (error) { - if (error instanceof V3SurveyReferenceValidationError) { - log.warn( - { statusCode: 400, workspaceId, invalidParamCount: error.invalidParams.length }, - "Survey document validation failed" - ); - return problemBadRequest(requestId, "Invalid survey document", { - invalid_params: error.invalidParams, - instance, - }); - } - - if (error instanceof V3SurveyUnsupportedShapeError) { - log.warn({ statusCode: 400, workspaceId, errorCode: error.name }, "Unsupported v3 survey shape"); - return problemBadRequest(requestId, error.message, { - instance, - invalid_params: [ - { - name: "survey", - reason: error.message, - }, - ], - }); - } - - if (error instanceof V3SurveyWritePermissionError) { - log.warn( - { statusCode: 403, workspaceId, errorCode: error.name }, - "Survey patch permission check failed" - ); - return problemForbidden(requestId, error.message, instance); - } - - if (error instanceof ResourceNotFoundError) { - log.warn( - { errorCode: error.name, workspaceId, statusCode: 403 }, - "Survey not found or not accessible" - ); - return problemForbidden(requestId, "You are not authorized to access this resource", instance); - } - - if (error instanceof DatabaseError) { - log.error({ error, workspaceId, statusCode: 500 }, "Database error"); - return problemInternalError(requestId, "An unexpected error occurred.", instance); - } - - log.error({ error, workspaceId, statusCode: 500 }, "V3 survey patch unexpected error"); - return problemInternalError(requestId, "An unexpected error occurred.", instance); - } + return await patchV3SurveyResponse({ + surveyId: parsedInput.params.surveyId, + body: parsedInput.body, + authentication, + requestId, + instance, + auditLog, + }); }, }); @@ -229,45 +75,12 @@ export const DELETE = withV3ApiWrapper({ params: surveyParamsSchema, }, handler: async ({ parsedInput, authentication, requestId, instance, auditLog }) => { - const surveyId = parsedInput.params.surveyId; - const log = logger.withContext({ requestId, surveyId }); - - try { - const { survey, authResult, response } = await getAuthorizedV3Survey({ - surveyId, - authentication, - access: "readWrite", - requestId, - instance, - }); - - if (response) { - log.warn({ statusCode: 403 }, "Survey not found or not accessible"); - return response; - } - - if (auditLog) { - auditLog.targetId = survey.id; - auditLog.organizationId = authResult.organizationId; - auditLog.oldObject = survey; - } - - await deleteSurvey(surveyId); - - return noContentResponse({ requestId }); - } catch (error) { - if (error instanceof ResourceNotFoundError) { - log.warn({ errorCode: error.name, statusCode: 403 }, "Survey not found or not accessible"); - return problemForbidden(requestId, "You are not authorized to access this resource", instance); - } - - if (error instanceof DatabaseError) { - log.error({ error, statusCode: 500 }, "Database error"); - return problemInternalError(requestId, "An unexpected error occurred.", instance); - } - - log.error({ error, statusCode: 500 }, "V3 survey delete unexpected error"); - return problemInternalError(requestId, "An unexpected error occurred.", instance); - } + return await deleteV3Survey({ + surveyId: parsedInput.params.surveyId, + authentication, + requestId, + instance, + auditLog, + }); }, }); diff --git a/apps/web/app/api/v3/surveys/lib/operations.test.ts b/apps/web/app/api/v3/surveys/lib/operations.test.ts new file mode 100644 index 000000000000..a5df3dc9693b --- /dev/null +++ b/apps/web/app/api/v3/surveys/lib/operations.test.ts @@ -0,0 +1,766 @@ +import { describe, expect, test, vi, beforeEach } from "vitest"; +import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors"; +import { problemForbidden } from "@/app/api/v3/lib/response"; +import { requireV3WorkspaceAccess } from "@/app/api/v3/lib/auth"; +import { deleteSurvey } from "@/modules/survey/lib/surveys"; +import { getSurveyCount } from "@/modules/survey/list/lib/survey"; +import { getSurveyListPage } from "@/modules/survey/list/lib/survey-page"; +import { getAuthorizedV3Survey } from "../authorization"; +import { V3SurveyCreatePermissionError, createV3Survey } from "../create"; +import { parseV3SurveysListQuery } from "../parse-v3-surveys-list-query"; +import { patchV3Survey } from "../patch"; +import { prepareV3SurveyCreateInput, prepareV3SurveyPatchInput } from "../prepare"; +import { V3SurveyReferenceValidationError } from "../reference-validation"; +import { + V3SurveyLanguageError, + V3SurveyUnsupportedShapeError, + serializeV3SurveyListItem, + serializeV3SurveyResource, +} from "../serializers"; +import { + createV3SurveyResponse, + deleteV3Survey, + getV3Survey, + listV3Surveys, + patchV3SurveyResponse, + validateV3Survey, +} from "./operations"; +import { V3SurveyWritePermissionError } from "../write-permissions"; + +vi.mock("@formbricks/logger", () => ({ + logger: { + withContext: vi.fn(() => ({ + warn: vi.fn(), + error: vi.fn(), + })), + }, +})); + +vi.mock("@/app/api/v3/lib/auth", () => ({ + requireV3WorkspaceAccess: vi.fn(), +})); + +vi.mock("@/modules/survey/lib/surveys", () => ({ + deleteSurvey: vi.fn(), +})); + +vi.mock("@/modules/survey/list/lib/survey", () => ({ + getSurveyCount: vi.fn(), +})); + +vi.mock("@/modules/survey/list/lib/survey-page", () => ({ + getSurveyListPage: vi.fn(), +})); + +vi.mock("../authorization", () => ({ + getAuthorizedV3Survey: vi.fn(), +})); + +vi.mock("../create", async () => { + const actual = await vi.importActual("../create"); + return { + ...actual, + createV3Survey: vi.fn(), + }; +}); + +vi.mock("../parse-v3-surveys-list-query", () => ({ + parseV3SurveysListQuery: vi.fn(), +})); + +vi.mock("../patch", () => ({ + patchV3Survey: vi.fn(), +})); + +vi.mock("../prepare", () => ({ + prepareV3SurveyCreateInput: vi.fn(), + prepareV3SurveyPatchInput: vi.fn(), +})); + +vi.mock("../serializers", async () => { + const actual = await vi.importActual("../serializers"); + return { + ...actual, + serializeV3SurveyListItem: vi.fn(), + serializeV3SurveyResource: vi.fn(), + }; +}); + +const workspaceId = "tz4a98xxat96iws9zmbrgj3a"; +const requestId = "req_123"; +const instance = "/api/v3/surveys"; +const authentication = { type: "apiKey", apiKey: { id: "api_key_1" } } as any; +const authResult = { workspaceId, organizationId: "org_1" }; +const survey = { + id: "survey_1", + workspaceId, + name: "Customer Survey", + status: "draft", +}; +const serializedSurvey = { + id: "survey_1", + name: "Customer Survey", +}; +const updatedSurvey = { + ...survey, + name: "Updated Survey", +}; +const serializedUpdatedSurvey = { + id: "survey_1", + name: "Updated Survey", +}; +const createBody = { + workspaceId, + name: "Customer Survey", + status: "draft", + metadata: {}, + welcomeCard: { enabled: false }, + blocks: [], + endings: [], + hiddenFields: { enabled: false, fieldIds: [] }, + variables: [], +} as any; + +function mockListQuery(overrides: Record = {}) { + vi.mocked(parseV3SurveysListQuery).mockReturnValue({ + ok: true, + workspaceId, + limit: 20, + cursor: null, + sortBy: undefined, + filterCriteria: {}, + includeTotalCount: true, + ...overrides, + } as any); +} + +async function readJson(response: Response) { + return response.json(); +} + +describe("listV3Surveys", () => { + beforeEach(() => { + vi.resetAllMocks(); + mockListQuery(); + vi.mocked(requireV3WorkspaceAccess).mockResolvedValue(authResult); + vi.mocked(getSurveyListPage).mockResolvedValue({ surveys: [survey], nextCursor: "cursor_next" } as any); + vi.mocked(getSurveyCount).mockResolvedValue(7); + vi.mocked(serializeV3SurveyListItem).mockReturnValue(serializedSurvey as any); + }); + + test("returns a serialized paginated survey list", async () => { + const response = await listV3Surveys({ + searchParams: new URLSearchParams({ workspaceId }), + authentication, + requestId, + instance, + }); + + expect(response.status).toBe(200); + expect(vi.mocked(requireV3WorkspaceAccess)).toHaveBeenCalledWith( + authentication, + workspaceId, + "read", + requestId, + instance + ); + expect(vi.mocked(getSurveyListPage)).toHaveBeenCalledWith(workspaceId, { + limit: 20, + cursor: null, + sortBy: undefined, + filterCriteria: {}, + }); + expect(await readJson(response)).toEqual({ + data: [serializedSurvey], + meta: { limit: 20, nextCursor: "cursor_next", totalCount: 7 }, + }); + }); + + test("skips total count when it is not requested", async () => { + mockListQuery({ includeTotalCount: false }); + + const response = await listV3Surveys({ + searchParams: new URLSearchParams({ workspaceId }), + authentication, + requestId, + instance, + }); + + expect(response.status).toBe(200); + expect(vi.mocked(getSurveyCount)).not.toHaveBeenCalled(); + expect((await readJson(response)).meta.totalCount).toBeNull(); + }); + + test("returns bad request for invalid query parameters", async () => { + vi.mocked(parseV3SurveysListQuery).mockReturnValue({ + ok: false, + invalid_params: [{ name: "workspaceId", reason: "Required" }], + } as any); + + const response = await listV3Surveys({ + searchParams: new URLSearchParams(), + authentication, + requestId, + instance, + }); + + expect(response.status).toBe(400); + expect((await readJson(response)).invalid_params).toEqual([ + { name: "workspaceId", reason: "Required" }, + ]); + }); + + test("returns authorization responses from workspace access", async () => { + vi.mocked(requireV3WorkspaceAccess).mockResolvedValue(problemForbidden(requestId, "nope", instance)); + + const response = await listV3Surveys({ + searchParams: new URLSearchParams({ workspaceId }), + authentication, + requestId, + instance, + }); + + expect(response.status).toBe(403); + expect(vi.mocked(getSurveyListPage)).not.toHaveBeenCalled(); + }); + + test("maps resource and database failures to v3 problem responses", async () => { + vi.mocked(getSurveyListPage).mockRejectedValueOnce(new ResourceNotFoundError("Workspace", workspaceId)); + + const forbidden = await listV3Surveys({ + searchParams: new URLSearchParams({ workspaceId }), + authentication, + requestId, + instance, + }); + expect(forbidden.status).toBe(403); + + vi.mocked(getSurveyListPage).mockRejectedValueOnce(new DatabaseError("db down")); + const internal = await listV3Surveys({ + searchParams: new URLSearchParams({ workspaceId }), + authentication, + requestId, + instance, + }); + expect(internal.status).toBe(500); + }); +}); + +describe("createV3SurveyResponse", () => { + beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(requireV3WorkspaceAccess).mockResolvedValue(authResult); + vi.mocked(createV3Survey).mockResolvedValue(survey as any); + vi.mocked(serializeV3SurveyResource).mockReturnValue(serializedSurvey as any); + }); + + test("creates a survey, serializes it, and enriches the audit log", async () => { + const auditLog = {} as any; + + const response = await createV3SurveyResponse({ + body: createBody, + authentication, + requestId, + instance, + auditLog, + }); + + expect(response.status).toBe(201); + expect(response.headers.get("Location")).toBe("/api/v3/surveys/survey_1"); + expect(vi.mocked(createV3Survey)).toHaveBeenCalledWith( + { ...createBody, workspaceId }, + authentication, + requestId, + "org_1" + ); + expect(auditLog).toMatchObject({ + organizationId: "org_1", + targetId: "survey_1", + newObject: serializedSurvey, + }); + expect(await readJson(response)).toEqual({ data: serializedSurvey }); + }); + + test("returns authorization responses from workspace access", async () => { + vi.mocked(requireV3WorkspaceAccess).mockResolvedValue(problemForbidden(requestId, "nope", instance)); + + const response = await createV3SurveyResponse({ + body: createBody, + authentication, + requestId, + instance, + }); + + expect(response.status).toBe(403); + expect(vi.mocked(createV3Survey)).not.toHaveBeenCalled(); + }); + + test("maps validation, shape, permission, missing resource, and database errors", async () => { + vi.mocked(createV3Survey).mockRejectedValueOnce( + new V3SurveyReferenceValidationError([{ name: "blocks.0", reason: "Unknown element" }]) + ); + expect( + ( + await createV3SurveyResponse({ + body: createBody, + authentication, + requestId, + instance, + }) + ).status + ).toBe(400); + + vi.mocked(createV3Survey).mockRejectedValueOnce(new V3SurveyUnsupportedShapeError("Unsupported")); + expect( + ( + await createV3SurveyResponse({ + body: createBody, + authentication, + requestId, + instance, + }) + ).status + ).toBe(400); + + vi.mocked(createV3Survey).mockRejectedValueOnce(new V3SurveyCreatePermissionError("No external URLs")); + expect( + ( + await createV3SurveyResponse({ + body: createBody, + authentication, + requestId, + instance, + }) + ).status + ).toBe(403); + + vi.mocked(createV3Survey).mockRejectedValueOnce(new ResourceNotFoundError("Workspace", workspaceId)); + expect( + ( + await createV3SurveyResponse({ + body: createBody, + authentication, + requestId, + instance, + }) + ).status + ).toBe(403); + + vi.mocked(createV3Survey).mockRejectedValueOnce(new DatabaseError("db down")); + expect( + ( + await createV3SurveyResponse({ + body: createBody, + authentication, + requestId, + instance, + }) + ).status + ).toBe(500); + }); +}); + +describe("getV3Survey", () => { + beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(getAuthorizedV3Survey).mockResolvedValue({ survey, authResult, response: null } as any); + vi.mocked(serializeV3SurveyResource).mockReturnValue(serializedSurvey as any); + }); + + test("returns a serialized survey resource with language selection", async () => { + const response = await getV3Survey({ + surveyId: "survey_1", + lang: ["en-US"], + authentication, + requestId, + instance, + }); + + expect(response.status).toBe(200); + expect(vi.mocked(getAuthorizedV3Survey)).toHaveBeenCalledWith({ + surveyId: "survey_1", + authentication, + access: "read", + requestId, + instance, + }); + expect(vi.mocked(serializeV3SurveyResource)).toHaveBeenCalledWith(survey, { lang: ["en-US"] }); + expect(await readJson(response)).toEqual({ data: serializedSurvey }); + }); + + test("returns authorization responses from survey access", async () => { + vi.mocked(getAuthorizedV3Survey).mockResolvedValue({ + survey: null, + authResult: null, + response: problemForbidden(requestId, "nope", instance), + } as any); + + const response = await getV3Survey({ + surveyId: "survey_1", + authentication, + requestId, + instance, + }); + + expect(response.status).toBe(403); + }); + + test("maps serializer language and shape errors to bad requests", async () => { + vi.mocked(serializeV3SurveyResource).mockImplementationOnce(() => { + throw new V3SurveyLanguageError("Unknown language", "xx-YY"); + }); + const languageResponse = await getV3Survey({ + surveyId: "survey_1", + lang: ["xx-YY"], + authentication, + requestId, + instance, + }); + expect(languageResponse.status).toBe(400); + expect((await readJson(languageResponse)).invalid_params[0]).toMatchObject({ + name: "lang", + identifier: "xx-YY", + }); + + vi.mocked(serializeV3SurveyResource).mockImplementationOnce(() => { + throw new V3SurveyUnsupportedShapeError("Unsupported shape"); + }); + const shapeResponse = await getV3Survey({ + surveyId: "survey_1", + authentication, + requestId, + instance, + }); + expect(shapeResponse.status).toBe(400); + }); + + test("maps database errors from survey access", async () => { + vi.mocked(getAuthorizedV3Survey).mockRejectedValue(new DatabaseError("db down")); + + const response = await getV3Survey({ + surveyId: "survey_1", + authentication, + requestId, + instance, + }); + + expect(response.status).toBe(500); + }); +}); + +describe("deleteV3Survey", () => { + beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(getAuthorizedV3Survey).mockResolvedValue({ survey, authResult, response: null } as any); + vi.mocked(deleteSurvey).mockResolvedValue(undefined); + }); + + test("deletes an authorized survey and enriches the audit log", async () => { + const auditLog = {} as any; + + const response = await deleteV3Survey({ + surveyId: "survey_1", + authentication, + requestId, + instance, + auditLog, + }); + + expect(response.status).toBe(204); + expect(vi.mocked(deleteSurvey)).toHaveBeenCalledWith("survey_1"); + expect(auditLog).toMatchObject({ + organizationId: "org_1", + targetId: "survey_1", + oldObject: survey, + }); + }); + + test("returns authorization responses from survey access", async () => { + vi.mocked(getAuthorizedV3Survey).mockResolvedValue({ + survey: null, + authResult: null, + response: problemForbidden(requestId, "nope", instance), + } as any); + + const response = await deleteV3Survey({ + surveyId: "survey_1", + authentication, + requestId, + instance, + }); + + expect(response.status).toBe(403); + expect(vi.mocked(deleteSurvey)).not.toHaveBeenCalled(); + }); + + test("maps missing resource and database delete failures", async () => { + vi.mocked(deleteSurvey).mockRejectedValueOnce(new ResourceNotFoundError("Survey", "survey_1")); + expect( + ( + await deleteV3Survey({ + surveyId: "survey_1", + authentication, + requestId, + instance, + }) + ).status + ).toBe(403); + + vi.mocked(deleteSurvey).mockRejectedValueOnce(new DatabaseError("db down")); + expect( + ( + await deleteV3Survey({ + surveyId: "survey_1", + authentication, + requestId, + instance, + }) + ).status + ).toBe(500); + }); +}); + +describe("patchV3SurveyResponse", () => { + beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(getAuthorizedV3Survey).mockResolvedValue({ survey, authResult, response: null } as any); + vi.mocked(patchV3Survey).mockResolvedValue(updatedSurvey as any); + vi.mocked(serializeV3SurveyResource).mockImplementation((input) => { + return (input as any).name === "Updated Survey" + ? (serializedUpdatedSurvey as any) + : (serializedSurvey as any); + }); + }); + + test("patches an authorized survey, serializes it, and enriches the audit log", async () => { + const auditLog = {} as any; + const patchBody = { name: "Updated Survey" }; + + const response = await patchV3SurveyResponse({ + surveyId: "survey_1", + body: patchBody, + authentication, + requestId, + instance, + auditLog, + }); + + expect(response.status).toBe(200); + expect(vi.mocked(getAuthorizedV3Survey)).toHaveBeenCalledWith({ + surveyId: "survey_1", + authentication, + access: "readWrite", + requestId, + instance, + }); + expect(vi.mocked(patchV3Survey)).toHaveBeenCalledWith(survey, patchBody, requestId, "org_1"); + expect(auditLog).toMatchObject({ + organizationId: "org_1", + targetId: "survey_1", + oldObject: serializedSurvey, + newObject: serializedUpdatedSurvey, + }); + expect(await readJson(response)).toEqual({ data: serializedUpdatedSurvey }); + }); + + test("returns authorization responses from survey access", async () => { + vi.mocked(getAuthorizedV3Survey).mockResolvedValue({ + survey: null, + authResult: null, + response: problemForbidden(requestId, "nope", instance), + } as any); + + const response = await patchV3SurveyResponse({ + surveyId: "survey_1", + body: { name: "Updated Survey" }, + authentication, + requestId, + instance, + }); + + expect(response.status).toBe(403); + expect(vi.mocked(patchV3Survey)).not.toHaveBeenCalled(); + }); + + test("maps validation, shape, permission, missing resource, and database errors", async () => { + vi.mocked(patchV3Survey).mockRejectedValueOnce( + new V3SurveyReferenceValidationError([{ name: "blocks.0", reason: "Unknown element" }]) + ); + expect( + ( + await patchV3SurveyResponse({ + surveyId: "survey_1", + body: { blocks: [] }, + authentication, + requestId, + instance, + }) + ).status + ).toBe(400); + + vi.mocked(patchV3Survey).mockRejectedValueOnce(new V3SurveyUnsupportedShapeError("Unsupported")); + expect( + ( + await patchV3SurveyResponse({ + surveyId: "survey_1", + body: { blocks: [] }, + authentication, + requestId, + instance, + }) + ).status + ).toBe(400); + + vi.mocked(patchV3Survey).mockRejectedValueOnce(new V3SurveyWritePermissionError("No external URLs")); + expect( + ( + await patchV3SurveyResponse({ + surveyId: "survey_1", + body: { blocks: [] }, + authentication, + requestId, + instance, + }) + ).status + ).toBe(403); + + vi.mocked(patchV3Survey).mockRejectedValueOnce(new ResourceNotFoundError("Survey", "survey_1")); + expect( + ( + await patchV3SurveyResponse({ + surveyId: "survey_1", + body: { blocks: [] }, + authentication, + requestId, + instance, + }) + ).status + ).toBe(403); + + vi.mocked(patchV3Survey).mockRejectedValueOnce(new DatabaseError("db down")); + expect( + ( + await patchV3SurveyResponse({ + surveyId: "survey_1", + body: { blocks: [] }, + authentication, + requestId, + instance, + }) + ).status + ).toBe(500); + }); +}); + +describe("validateV3Survey", () => { + beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(requireV3WorkspaceAccess).mockResolvedValue(authResult); + vi.mocked(getAuthorizedV3Survey).mockResolvedValue({ survey, authResult, response: null } as any); + vi.mocked(prepareV3SurveyCreateInput).mockReturnValue({ + ok: true, + languageRequests: [{ code: "en-US", default: true, enabled: true }], + } as any); + vi.mocked(prepareV3SurveyPatchInput).mockReturnValue({ + ok: false, + validation: { invalidParams: [{ name: "name", reason: "Required" }] }, + } as any); + }); + + test("validates create input and checks workspace access when workspaceId is present", async () => { + const response = await validateV3Survey({ + body: { operation: "create", data: createBody }, + authentication, + requestId, + instance, + } as any); + + expect(response.status).toBe(200); + expect(vi.mocked(requireV3WorkspaceAccess)).toHaveBeenCalledWith( + authentication, + workspaceId, + "readWrite", + requestId, + instance + ); + expect(await readJson(response)).toEqual({ + data: { + valid: true, + operation: "create", + invalid_params: [], + languages: [{ code: "en-US", default: true, enabled: true, writeBehavior: "connect_or_create" }], + }, + }); + }); + + test("returns authorization responses while validating create input", async () => { + vi.mocked(requireV3WorkspaceAccess).mockResolvedValue(problemForbidden(requestId, "nope", instance)); + + const response = await validateV3Survey({ + body: { operation: "create", data: createBody }, + authentication, + requestId, + instance, + } as any); + + expect(response.status).toBe(403); + expect(vi.mocked(prepareV3SurveyCreateInput)).not.toHaveBeenCalled(); + }); + + test("validates patch input against the authorized survey", async () => { + const response = await validateV3Survey({ + body: { operation: "patch", surveyId: "survey_1", data: { name: "" } }, + authentication, + requestId, + instance, + } as any); + + expect(response.status).toBe(200); + expect(vi.mocked(getAuthorizedV3Survey)).toHaveBeenCalledWith({ + surveyId: "survey_1", + authentication, + access: "readWrite", + requestId, + instance, + }); + expect(vi.mocked(prepareV3SurveyPatchInput)).toHaveBeenCalledWith(survey, { name: "" }); + expect(await readJson(response)).toEqual({ + data: { + valid: false, + operation: "patch", + invalid_params: [{ name: "name", reason: "Required" }], + }, + }); + }); + + test("returns authorization responses while validating patch input", async () => { + vi.mocked(getAuthorizedV3Survey).mockResolvedValue({ + survey: null, + authResult: null, + response: problemForbidden(requestId, "nope", instance), + } as any); + + const response = await validateV3Survey({ + body: { operation: "patch", surveyId: "survey_1", data: {} }, + authentication, + requestId, + instance, + } as any); + + expect(response.status).toBe(403); + expect(vi.mocked(prepareV3SurveyPatchInput)).not.toHaveBeenCalled(); + }); + + test("maps database errors during validation", async () => { + vi.mocked(getAuthorizedV3Survey).mockRejectedValue(new DatabaseError("db down")); + + const response = await validateV3Survey({ + body: { operation: "patch", surveyId: "survey_1", data: {} }, + authentication, + requestId, + instance, + } as any); + + expect(response.status).toBe(500); + }); +}); diff --git a/apps/web/app/api/v3/surveys/lib/operations.ts b/apps/web/app/api/v3/surveys/lib/operations.ts new file mode 100644 index 000000000000..bd8a5c5d03bb --- /dev/null +++ b/apps/web/app/api/v3/surveys/lib/operations.ts @@ -0,0 +1,514 @@ +import "server-only"; +import { z } from "zod"; +import { logger } from "@formbricks/logger"; +import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors"; +import { requireV3WorkspaceAccess } from "@/app/api/v3/lib/auth"; +import { + createdResponse, + noContentResponse, + problemBadRequest, + problemForbidden, + problemInternalError, + successListResponse, + successResponse, +} from "@/app/api/v3/lib/response"; +import type { TV3AuditLog, TV3Authentication } from "@/app/api/v3/lib/types"; +import { deleteSurvey } from "@/modules/survey/lib/surveys"; +import { getSurveyCount } from "@/modules/survey/list/lib/survey"; +import { getSurveyListPage } from "@/modules/survey/list/lib/survey-page"; +import { getAuthorizedV3Survey } from "../authorization"; +import { V3SurveyCreatePermissionError, createV3Survey } from "../create"; +import { parseV3SurveysListQuery } from "../parse-v3-surveys-list-query"; +import { patchV3Survey } from "../patch"; +import { + type TV3SurveyPrepareResult, + prepareV3SurveyCreateInput, + prepareV3SurveyPatchInput, +} from "../prepare"; +import { V3SurveyReferenceValidationError } from "../reference-validation"; +import type { TV3CreateSurveyBody, TV3SurveyDocument, TV3SurveyValidationRequestBody } from "../schemas"; +import { + V3SurveyLanguageError, + V3SurveyUnsupportedShapeError, + serializeV3SurveyListItem, + serializeV3SurveyResource, +} from "../serializers"; +import { V3SurveyWritePermissionError } from "../write-permissions"; + +type TListV3SurveysParams = { + searchParams: URLSearchParams; + authentication: TV3Authentication; + requestId: string; + instance: string; +}; + +type TCreateV3SurveyParams = { + body: TV3CreateSurveyBody; + authentication: TV3Authentication; + requestId: string; + instance: string; + auditLog?: TV3AuditLog; +}; + +type TGetV3SurveyParams = { + surveyId: string; + lang?: string[]; + authentication: TV3Authentication; + requestId: string; + instance: string; +}; + +type TDeleteV3SurveyParams = { + surveyId: string; + authentication: TV3Authentication; + requestId: string; + instance: string; + auditLog?: TV3AuditLog; +}; + +type TPatchV3SurveyParams = { + surveyId: string; + body: unknown; + authentication: TV3Authentication; + requestId: string; + instance: string; + auditLog?: TV3AuditLog; +}; + +type TValidateV3SurveyParams = { + body: TV3SurveyValidationRequestBody; + authentication: TV3Authentication; + requestId: string; + instance: string; +}; + +const createWorkspaceIdSchema = z.object({ + workspaceId: z.cuid2(), +}); + +function serializeValidationResult( + operation: "create" | "patch", + preparation: TV3SurveyPrepareResult +) { + if (!preparation.ok) { + return { + valid: false, + operation, + invalid_params: preparation.validation.invalidParams, + }; + } + + return { + valid: true, + operation, + invalid_params: [], + languages: preparation.languageRequests.map((languageRequest) => ({ + ...languageRequest, + writeBehavior: "connect_or_create" as const, + })), + }; +} + +export async function listV3Surveys({ + searchParams, + authentication, + requestId, + instance, +}: TListV3SurveysParams): Promise { + const log = logger.withContext({ requestId }); + + try { + const parsed = parseV3SurveysListQuery(searchParams); + if (!parsed.ok) { + log.warn({ statusCode: 400, invalidParams: parsed.invalid_params }, "Validation failed"); + return problemBadRequest(requestId, "Invalid query parameters", { + invalid_params: parsed.invalid_params, + instance, + }); + } + + const authResult = await requireV3WorkspaceAccess( + authentication, + parsed.workspaceId, + "read", + requestId, + instance + ); + if (authResult instanceof Response) { + return authResult; + } + + const { workspaceId } = authResult; + + const surveyPagePromise = getSurveyListPage(workspaceId, { + limit: parsed.limit, + cursor: parsed.cursor, + sortBy: parsed.sortBy, + filterCriteria: parsed.filterCriteria, + }); + const totalCountPromise = parsed.includeTotalCount + ? getSurveyCount(workspaceId, parsed.filterCriteria) + : Promise.resolve(null); + const [surveyPage, totalCount] = await Promise.all([surveyPagePromise, totalCountPromise]); + + return successListResponse( + surveyPage.surveys.map(serializeV3SurveyListItem), + { + limit: parsed.limit, + nextCursor: surveyPage.nextCursor, + totalCount, + }, + { requestId, cache: "private, no-store" } + ); + } catch (err) { + if (err instanceof ResourceNotFoundError) { + log.warn({ statusCode: 403, errorCode: err.name }, "Resource not found"); + return problemForbidden(requestId, "You are not authorized to access this resource", instance); + } + if (err instanceof DatabaseError) { + log.error({ error: err, statusCode: 500 }, "Database error"); + return problemInternalError(requestId, "An unexpected error occurred.", instance); + } + log.error({ error: err, statusCode: 500 }, "V3 surveys list unexpected error"); + return problemInternalError(requestId, "An unexpected error occurred.", instance); + } +} + +export async function createV3SurveyResponse({ + body, + authentication, + requestId, + instance, + auditLog, +}: TCreateV3SurveyParams): Promise { + const log = logger.withContext({ requestId, workspaceId: body.workspaceId }); + + try { + const authResult = await requireV3WorkspaceAccess( + authentication, + body.workspaceId, + "readWrite", + requestId, + instance + ); + if (authResult instanceof Response) { + return authResult; + } + + const survey = await createV3Survey( + { + ...body, + workspaceId: authResult.workspaceId, + }, + authentication, + requestId, + authResult.organizationId + ); + const resource = serializeV3SurveyResource(survey); + + if (auditLog) { + auditLog.organizationId = authResult.organizationId; + auditLog.targetId = survey.id; + auditLog.newObject = resource; + } + + return createdResponse(resource, { + requestId, + location: `/api/v3/surveys/${survey.id}`, + }); + } catch (err) { + if (err instanceof V3SurveyReferenceValidationError) { + log.warn({ statusCode: 400, invalidParams: err.invalidParams }, "Survey document validation failed"); + return problemBadRequest(requestId, "Invalid survey document", { + invalid_params: err.invalidParams, + instance, + }); + } + if (err instanceof V3SurveyUnsupportedShapeError) { + log.warn({ statusCode: 400, errorCode: err.name }, "Unsupported survey shape"); + return problemBadRequest(requestId, err.message, { + invalid_params: [{ name: "body", reason: err.message }], + instance, + }); + } + if (err instanceof V3SurveyCreatePermissionError) { + log.warn({ statusCode: 403, errorCode: err.name }, "Survey create permission check failed"); + return problemForbidden(requestId, err.message, instance); + } + if (err instanceof ResourceNotFoundError) { + log.warn({ statusCode: 403, errorCode: err.name }, "Resource not found"); + return problemForbidden(requestId, "You are not authorized to access this resource", instance); + } + if (err instanceof DatabaseError) { + log.error({ error: err, statusCode: 500 }, "Database error"); + return problemInternalError(requestId, "An unexpected error occurred.", instance); + } + + log.error({ error: err, statusCode: 500 }, "V3 survey create unexpected error"); + return problemInternalError(requestId, "An unexpected error occurred.", instance); + } +} + +export async function getV3Survey({ + surveyId, + lang, + authentication, + requestId, + instance, +}: TGetV3SurveyParams): Promise { + const log = logger.withContext({ requestId, surveyId }); + + try { + const { survey, response } = await getAuthorizedV3Survey({ + surveyId, + authentication, + access: "read", + requestId, + instance, + }); + + if (response) { + log.warn({ statusCode: response.status }, "Survey not found or not accessible"); + return response; + } + + try { + return successResponse(serializeV3SurveyResource(survey, { lang }), { + requestId, + cache: "private, no-store", + }); + } catch (error) { + if (error instanceof V3SurveyLanguageError) { + log.warn({ statusCode: 400, detail: error.message, lang }, "Invalid survey language selector"); + return problemBadRequest(requestId, error.message, { + instance, + invalid_params: [ + { + name: "lang", + reason: error.message, + ...(error.normalizedCode && { identifier: error.normalizedCode }), + }, + ], + }); + } + + if (error instanceof V3SurveyUnsupportedShapeError) { + log.warn({ statusCode: 400, detail: error.message }, "Unsupported v3 survey shape"); + return problemBadRequest(requestId, error.message, { + instance, + invalid_params: [ + { + name: "survey", + reason: error.message, + }, + ], + }); + } + + throw error; + } + } catch (error) { + if (error instanceof DatabaseError) { + log.error({ error, statusCode: 500 }, "Database error"); + return problemInternalError(requestId, "An unexpected error occurred.", instance); + } + + log.error({ error, statusCode: 500 }, "V3 survey get unexpected error"); + return problemInternalError(requestId, "An unexpected error occurred.", instance); + } +} + +export async function deleteV3Survey({ + surveyId, + authentication, + requestId, + instance, + auditLog, +}: TDeleteV3SurveyParams): Promise { + const log = logger.withContext({ requestId, surveyId }); + + try { + const { survey, authResult, response } = await getAuthorizedV3Survey({ + surveyId, + authentication, + access: "readWrite", + requestId, + instance, + }); + + if (response) { + log.warn({ statusCode: 403 }, "Survey not found or not accessible"); + return response; + } + + if (auditLog) { + auditLog.targetId = survey.id; + auditLog.organizationId = authResult.organizationId; + auditLog.oldObject = survey; + } + + await deleteSurvey(surveyId); + + return noContentResponse({ requestId }); + } catch (error) { + if (error instanceof ResourceNotFoundError) { + log.warn({ errorCode: error.name, statusCode: 403 }, "Survey not found or not accessible"); + return problemForbidden(requestId, "You are not authorized to access this resource", instance); + } + + if (error instanceof DatabaseError) { + log.error({ error, statusCode: 500 }, "Database error"); + return problemInternalError(requestId, "An unexpected error occurred.", instance); + } + + log.error({ error, statusCode: 500 }, "V3 survey delete unexpected error"); + return problemInternalError(requestId, "An unexpected error occurred.", instance); + } +} + +export async function patchV3SurveyResponse({ + surveyId, + body, + authentication, + requestId, + instance, + auditLog, +}: TPatchV3SurveyParams): Promise { + const log = logger.withContext({ requestId, surveyId }); + let workspaceId: string | undefined; + + try { + const { survey, authResult, response } = await getAuthorizedV3Survey({ + surveyId, + authentication, + access: "readWrite", + requestId, + instance, + }); + + if (response) { + log.warn({ statusCode: response.status }, "Survey not found or not accessible"); + return response; + } + + workspaceId = survey.workspaceId; + const updatedSurvey = await patchV3Survey(survey, body, requestId, authResult.organizationId); + const resource = serializeV3SurveyResource(updatedSurvey); + + if (auditLog) { + auditLog.targetId = updatedSurvey.id; + auditLog.organizationId = authResult.organizationId; + auditLog.oldObject = serializeV3SurveyResource(survey); + auditLog.newObject = resource; + } + + return successResponse(resource, { + requestId, + cache: "private, no-store", + }); + } catch (error) { + if (error instanceof V3SurveyReferenceValidationError) { + log.warn( + { statusCode: 400, workspaceId, invalidParamCount: error.invalidParams.length }, + "Survey document validation failed" + ); + return problemBadRequest(requestId, "Invalid survey document", { + invalid_params: error.invalidParams, + instance, + }); + } + + if (error instanceof V3SurveyUnsupportedShapeError) { + log.warn({ statusCode: 400, workspaceId, errorCode: error.name }, "Unsupported v3 survey shape"); + return problemBadRequest(requestId, error.message, { + instance, + invalid_params: [ + { + name: "survey", + reason: error.message, + }, + ], + }); + } + + if (error instanceof V3SurveyWritePermissionError) { + log.warn({ statusCode: 403, workspaceId, errorCode: error.name }, "Survey patch permission check failed"); + return problemForbidden(requestId, error.message, instance); + } + + if (error instanceof ResourceNotFoundError) { + log.warn({ errorCode: error.name, workspaceId, statusCode: 403 }, "Survey not found or not accessible"); + return problemForbidden(requestId, "You are not authorized to access this resource", instance); + } + + if (error instanceof DatabaseError) { + log.error({ error, workspaceId, statusCode: 500 }, "Database error"); + return problemInternalError(requestId, "An unexpected error occurred.", instance); + } + + log.error({ error, workspaceId, statusCode: 500 }, "V3 survey patch unexpected error"); + return problemInternalError(requestId, "An unexpected error occurred.", instance); + } +} + +export async function validateV3Survey({ + body, + authentication, + requestId, + instance, +}: TValidateV3SurveyParams): Promise { + const log = logger.withContext({ requestId, operation: body.operation }); + + try { + if (body.operation === "create") { + const workspaceResult = createWorkspaceIdSchema.safeParse(body.data); + if (workspaceResult.success) { + const authResult = await requireV3WorkspaceAccess( + authentication, + workspaceResult.data.workspaceId, + "readWrite", + requestId, + instance + ); + + if (authResult instanceof Response) { + return authResult; + } + } + + return successResponse(serializeValidationResult("create", prepareV3SurveyCreateInput(body.data)), { + requestId, + cache: "private, no-store", + }); + } + + const { survey, response } = await getAuthorizedV3Survey({ + surveyId: body.surveyId, + authentication, + access: "readWrite", + requestId, + instance, + }); + + if (response) { + log.warn({ statusCode: response.status, surveyId: body.surveyId }, "Survey not found or not accessible"); + return response; + } + + return successResponse( + serializeValidationResult("patch", prepareV3SurveyPatchInput(survey, body.data)), + { + requestId, + cache: "private, no-store", + } + ); + } catch (error) { + if (error instanceof DatabaseError) { + log.error({ error, statusCode: 500 }, "Database error"); + return problemInternalError(requestId, "An unexpected error occurred.", instance); + } + + log.error({ error, statusCode: 500 }, "V3 survey validation unexpected error"); + return problemInternalError(requestId, "An unexpected error occurred.", instance); + } +} diff --git a/apps/web/app/api/v3/surveys/route.ts b/apps/web/app/api/v3/surveys/route.ts index 508c8779b57d..d428a73caf8d 100644 --- a/apps/web/app/api/v3/surveys/route.ts +++ b/apps/web/app/api/v3/surveys/route.ts @@ -1,91 +1,20 @@ /** - * /api/v3/surveys — list and create block-based survey management resources. + * /api/v3/surveys - list and create block-based survey management resources. * Session cookie or x-api-key; scope by workspaceId only. */ -import { logger } from "@formbricks/logger"; -import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors"; import { withV3ApiWrapper } from "@/app/api/v3/lib/api-wrapper"; -import { requireV3WorkspaceAccess } from "@/app/api/v3/lib/auth"; -import { - createdResponse, - problemBadRequest, - problemForbidden, - problemInternalError, - successListResponse, -} from "@/app/api/v3/lib/response"; -import { getSurveyCount } from "@/modules/survey/list/lib/survey"; -import { getSurveyListPage } from "@/modules/survey/list/lib/survey-page"; -import { V3SurveyCreatePermissionError, createV3Survey } from "./create"; -import { parseV3SurveysListQuery } from "./parse-v3-surveys-list-query"; -import { V3SurveyReferenceValidationError } from "./reference-validation"; +import { createV3SurveyResponse, listV3Surveys } from "./lib/operations"; import { ZV3CreateSurveyBody } from "./schemas"; -import { - V3SurveyUnsupportedShapeError, - serializeV3SurveyListItem, - serializeV3SurveyResource, -} from "./serializers"; export const GET = withV3ApiWrapper({ auth: "both", handler: async ({ req, authentication, requestId, instance }) => { - const log = logger.withContext({ requestId }); - - try { - const searchParams = new URL(req.url).searchParams; - const parsed = parseV3SurveysListQuery(searchParams); - if (!parsed.ok) { - log.warn({ statusCode: 400, invalidParams: parsed.invalid_params }, "Validation failed"); - return problemBadRequest(requestId, "Invalid query parameters", { - invalid_params: parsed.invalid_params, - instance, - }); - } - - const authResult = await requireV3WorkspaceAccess( - authentication, - parsed.workspaceId, - "read", - requestId, - instance - ); - if (authResult instanceof Response) { - return authResult; - } - - const { workspaceId } = authResult; - - const surveyPagePromise = getSurveyListPage(workspaceId, { - limit: parsed.limit, - cursor: parsed.cursor, - sortBy: parsed.sortBy, - filterCriteria: parsed.filterCriteria, - }); - const totalCountPromise = parsed.includeTotalCount - ? getSurveyCount(workspaceId, parsed.filterCriteria) - : Promise.resolve(null); - const [surveyPage, totalCount] = await Promise.all([surveyPagePromise, totalCountPromise]); - - return successListResponse( - surveyPage.surveys.map(serializeV3SurveyListItem), - { - limit: parsed.limit, - nextCursor: surveyPage.nextCursor, - totalCount, - }, - { requestId, cache: "private, no-store" } - ); - } catch (err) { - if (err instanceof ResourceNotFoundError) { - log.warn({ statusCode: 403, errorCode: err.name }, "Resource not found"); - return problemForbidden(requestId, "You are not authorized to access this resource", instance); - } - if (err instanceof DatabaseError) { - log.error({ error: err, statusCode: 500 }, "Database error"); - return problemInternalError(requestId, "An unexpected error occurred.", instance); - } - log.error({ error: err, statusCode: 500 }, "V3 surveys list unexpected error"); - return problemInternalError(requestId, "An unexpected error occurred.", instance); - } + return await listV3Surveys({ + searchParams: new URL(req.url).searchParams, + authentication, + requestId, + instance, + }); }, }); @@ -97,72 +26,12 @@ export const POST = withV3ApiWrapper({ action: "created", targetType: "survey", handler: async ({ authentication, auditLog, parsedInput, requestId, instance }) => { - const { body } = parsedInput; - const log = logger.withContext({ requestId, workspaceId: body.workspaceId }); - - try { - const authResult = await requireV3WorkspaceAccess( - authentication, - body.workspaceId, - "readWrite", - requestId, - instance - ); - if (authResult instanceof Response) { - return authResult; - } - - const survey = await createV3Survey( - { - ...body, - workspaceId: authResult.workspaceId, - }, - authentication, - requestId, - authResult.organizationId - ); - const resource = serializeV3SurveyResource(survey); - - if (auditLog) { - auditLog.organizationId = authResult.organizationId; - auditLog.targetId = survey.id; - auditLog.newObject = resource; - } - - return createdResponse(resource, { - requestId, - location: `/api/v3/surveys/${survey.id}`, - }); - } catch (err) { - if (err instanceof V3SurveyReferenceValidationError) { - log.warn({ statusCode: 400, invalidParams: err.invalidParams }, "Survey document validation failed"); - return problemBadRequest(requestId, "Invalid survey document", { - invalid_params: err.invalidParams, - instance, - }); - } - if (err instanceof V3SurveyUnsupportedShapeError) { - log.warn({ statusCode: 400, errorCode: err.name }, "Unsupported survey shape"); - return problemBadRequest(requestId, err.message, { - invalid_params: [{ name: "body", reason: err.message }], - instance, - }); - } - if (err instanceof V3SurveyCreatePermissionError) { - log.warn({ statusCode: 403, errorCode: err.name }, "Survey create permission check failed"); - return problemForbidden(requestId, err.message, instance); - } - if (err instanceof ResourceNotFoundError) { - log.warn({ statusCode: 403, errorCode: err.name }, "Resource not found"); - return problemForbidden(requestId, "You are not authorized to access this resource", instance); - } - if (err instanceof DatabaseError) { - log.error({ error: err, statusCode: 500 }, "Database error"); - return problemInternalError(requestId, "An unexpected error occurred.", instance); - } - - log.error({ error: err, statusCode: 500 }, "V3 survey create unexpected error"); - return problemInternalError(requestId, "An unexpected error occurred.", instance); - } + return await createV3SurveyResponse({ + body: parsedInput.body, + authentication, + requestId, + instance, + auditLog, + }); }, }); diff --git a/apps/web/app/api/v3/surveys/serializers.test.ts b/apps/web/app/api/v3/surveys/serializers.test.ts index 1aeff26d0d59..11207a5578ee 100644 --- a/apps/web/app/api/v3/surveys/serializers.test.ts +++ b/apps/web/app/api/v3/surveys/serializers.test.ts @@ -1,8 +1,10 @@ import { describe, expect, test } from "vitest"; import type { TSurvey } from "@formbricks/types/surveys/types"; +import type { TSurvey as TSurveyListRecord } from "@/modules/survey/list/types/surveys"; import { V3SurveyLanguageError, V3SurveyUnsupportedShapeError, + serializeV3SurveyListItem, serializeV3SurveyResource, } from "./serializers"; @@ -597,3 +599,40 @@ describe("serializeV3SurveyResource", () => { ); }); }); + +describe("serializeV3SurveyListItem", () => { + const baseListSurvey = { + id: "survey_1", + name: "Customer onboarding", + workspaceId: "workspace_1", + type: "link", + status: "draft", + publishOn: null, + createdAt: new Date("2026-04-15T10:00:00.000Z"), + updatedAt: new Date("2026-04-16T10:00:00.000Z"), + responseCount: 0, + singleUse: null, + } satisfies Omit; + + test("allowlists nested creator fields", () => { + const survey = { + ...baseListSurvey, + creator: { + name: "Ada", + email: "ada@example.com", + id: "user_1", + }, + } as unknown as TSurveyListRecord; + + expect(serializeV3SurveyListItem(survey).creator).toEqual({ name: "Ada" }); + }); + + test("preserves null creator", () => { + const survey = { + ...baseListSurvey, + creator: null, + } satisfies TSurveyListRecord; + + expect(serializeV3SurveyListItem(survey).creator).toBeNull(); + }); +}); diff --git a/apps/web/app/api/v3/surveys/serializers.ts b/apps/web/app/api/v3/surveys/serializers.ts index eb899316d2ec..127018b25446 100644 --- a/apps/web/app/api/v3/surveys/serializers.ts +++ b/apps/web/app/api/v3/surveys/serializers.ts @@ -11,7 +11,25 @@ import { } from "./language"; import { V3_SURVEY_TRANSLATABLE_METADATA_KEYS } from "./translation-fields"; -export type TV3SurveyListItem = Omit; +export type TV3SurveyCreator = Pick, "name">; + +type TV3SurveyListItemBase = Pick< + TSurveyListRecord, + | "id" + | "name" + | "workspaceId" + | "type" + | "status" + | "publishOn" + | "createdAt" + | "updatedAt" + | "responseCount" +>; + +export type TV3SurveyListItem = TV3SurveyListItemBase & { + creator: TV3SurveyCreator | null; +}; + const DEFAULT_V3_SURVEY_LANGUAGE = "en-US"; type TSerializedValue = @@ -39,14 +57,33 @@ export class V3SurveyUnsupportedShapeError extends Error { } } +export function serializeV3SurveyCreator(creator: TSurveyListRecord["creator"]): TV3SurveyCreator | null { + if (!creator) { + return null; + } + + return { + name: creator.name, + }; +} + /** * Keep the v3 API contract isolated from internal persistence naming. * Surveys are scoped by workspaceId. */ export function serializeV3SurveyListItem(survey: TSurveyListRecord): TV3SurveyListItem { - const { singleUse: _omitSingleUse, ...rest } = survey; - - return rest; + return { + id: survey.id, + name: survey.name, + workspaceId: survey.workspaceId, + type: survey.type, + status: survey.status, + publishOn: survey.publishOn, + createdAt: survey.createdAt, + updatedAt: survey.updatedAt, + responseCount: survey.responseCount, + creator: serializeV3SurveyCreator(survey.creator), + }; } function toIsoString(value: Date | string): string { diff --git a/apps/web/app/api/v3/surveys/validate/route.ts b/apps/web/app/api/v3/surveys/validate/route.ts index bc7f0c0b0471..a7a0fb1ff527 100644 --- a/apps/web/app/api/v3/surveys/validate/route.ts +++ b/apps/web/app/api/v3/surveys/validate/route.ts @@ -1,43 +1,6 @@ -import { z } from "zod"; -import { logger } from "@formbricks/logger"; -import { DatabaseError } from "@formbricks/types/errors"; import { withV3ApiWrapper } from "@/app/api/v3/lib/api-wrapper"; -import { requireV3WorkspaceAccess } from "@/app/api/v3/lib/auth"; -import { problemInternalError, successResponse } from "@/app/api/v3/lib/response"; -import { getAuthorizedV3Survey } from "../authorization"; -import { - type TV3SurveyPrepareResult, - prepareV3SurveyCreateInput, - prepareV3SurveyPatchInput, -} from "../prepare"; -import { type TV3SurveyDocument, ZV3EmptyQuery, ZV3SurveyValidationRequestBody } from "../schemas"; - -const createWorkspaceIdSchema = z.object({ - workspaceId: z.cuid2(), -}); - -function serializeValidationResult( - operation: "create" | "patch", - preparation: TV3SurveyPrepareResult -) { - if (!preparation.ok) { - return { - valid: false, - operation, - invalid_params: preparation.validation.invalidParams, - }; - } - - return { - valid: true, - operation, - invalid_params: [], - languages: preparation.languageRequests.map((languageRequest) => ({ - ...languageRequest, - writeBehavior: "connect_or_create" as const, - })), - }; -} +import { validateV3Survey } from "../lib/operations"; +import { ZV3EmptyQuery, ZV3SurveyValidationRequestBody } from "../schemas"; export const POST = withV3ApiWrapper({ auth: "both", @@ -46,63 +9,11 @@ export const POST = withV3ApiWrapper({ query: ZV3EmptyQuery, }, handler: async ({ parsedInput, authentication, requestId, instance }) => { - const { body } = parsedInput; - const log = logger.withContext({ requestId, operation: body.operation }); - - try { - if (body.operation === "create") { - const workspaceResult = createWorkspaceIdSchema.safeParse(body.data); - if (workspaceResult.success) { - const authResult = await requireV3WorkspaceAccess( - authentication, - workspaceResult.data.workspaceId, - "readWrite", - requestId, - instance - ); - - if (authResult instanceof Response) { - return authResult; - } - } - - return successResponse(serializeValidationResult("create", prepareV3SurveyCreateInput(body.data)), { - requestId, - cache: "private, no-store", - }); - } - - const { survey, response } = await getAuthorizedV3Survey({ - surveyId: body.surveyId, - authentication, - access: "readWrite", - requestId, - instance, - }); - - if (response) { - log.warn( - { statusCode: response.status, surveyId: body.surveyId }, - "Survey not found or not accessible" - ); - return response; - } - - return successResponse( - serializeValidationResult("patch", prepareV3SurveyPatchInput(survey, body.data)), - { - requestId, - cache: "private, no-store", - } - ); - } catch (error) { - if (error instanceof DatabaseError) { - log.error({ error, statusCode: 500 }, "Database error"); - return problemInternalError(requestId, "An unexpected error occurred.", instance); - } - - log.error({ error, statusCode: 500 }, "V3 survey validation unexpected error"); - return problemInternalError(requestId, "An unexpected error occurred.", instance); - } + return await validateV3Survey({ + body: parsedInput.body, + authentication, + requestId, + instance, + }); }, }); diff --git a/apps/web/modules/mcp/auth.test.ts b/apps/web/modules/mcp/auth.test.ts new file mode 100644 index 000000000000..412ef4a0c53a --- /dev/null +++ b/apps/web/modules/mcp/auth.test.ts @@ -0,0 +1,210 @@ +import { ApiKeyPermission } from "@prisma/client"; +import { NextRequest } from "next/server"; +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { TooManyRequestsError } from "@formbricks/types/errors"; +import { authenticateApiKeyFromHeaders } from "@/modules/api/lib/api-key-auth"; +import { applyRateLimit } from "@/modules/core/rate-limit/helpers"; +import { + authenticateMcpRequest, + getMcpAuthentication, + getMcpRequestId, + handleAuthenticatedMcpRequest, +} from "./auth"; + +vi.mock("@/modules/api/lib/api-key-auth", () => ({ + authenticateApiKeyFromHeaders: vi.fn(), +})); + +vi.mock("@/modules/core/rate-limit/helpers", () => ({ + applyRateLimit: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@/lib/getPublicUrl", () => ({ + getPublicDomain: vi.fn(() => "https://app.example.com"), +})); + +vi.mock("@formbricks/logger", () => ({ + logger: { + withContext: vi.fn(() => ({ + warn: vi.fn(), + error: vi.fn(), + })), + }, +})); + +const apiKeyAuth = { + type: "apiKey" as const, + apiKeyId: "key_1", + organizationId: "org_1", + organizationAccess: { + accessControl: { read: true, write: true }, + }, + workspacePermissions: [ + { + workspaceId: "workspace_1", + workspaceName: "Workspace", + permission: ApiKeyPermission.write, + }, + ], +}; + +function createRequest(url = "http://localhost/api/mcp", headers: Record = {}): NextRequest { + return new NextRequest(url, { + method: "POST", + headers, + }); +} + +describe("authenticateMcpRequest", () => { + beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(applyRateLimit).mockResolvedValue(undefined); + }); + + test("returns 401 when no API key authenticates", async () => { + vi.mocked(authenticateApiKeyFromHeaders).mockResolvedValue(null); + + const result = await authenticateMcpRequest(createRequest()); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.response.status).toBe(401); + expect(await result.response.json()).toMatchObject({ + code: "not_authenticated", + detail: "API key required", + }); + } + }); + + test("rejects API keys in query parameters", async () => { + const result = await authenticateMcpRequest(createRequest("http://localhost/api/mcp?apiKey=secret")); + + expect(result.ok).toBe(false); + expect(authenticateApiKeyFromHeaders).not.toHaveBeenCalled(); + if (!result.ok) { + expect(result.response.status).toBe(400); + const body = await result.response.json(); + expect(body.invalid_params[0].name).toBe("query"); + } + }); + + test("rejects query credential parameters case-insensitively", async () => { + const result = await authenticateMcpRequest( + createRequest("http://localhost/api/mcp?Authorization=Bearer%20secret") + ); + + expect(result.ok).toBe(false); + expect(authenticateApiKeyFromHeaders).not.toHaveBeenCalled(); + if (!result.ok) { + expect(result.response.status).toBe(400); + } + }); + + test("rejects cross-origin browser requests", async () => { + const result = await authenticateMcpRequest( + createRequest("https://app.example.com/api/mcp", { + origin: "https://evil.example.com", + host: "app.example.com", + }) + ); + + expect(result.ok).toBe(false); + expect(authenticateApiKeyFromHeaders).not.toHaveBeenCalled(); + if (!result.ok) { + expect(result.response.status).toBe(403); + } + }); + + test("does not trust forwarded host headers for origin validation", async () => { + const result = await authenticateMcpRequest( + createRequest("https://app.example.com/api/mcp", { + origin: "https://evil.example.com", + "x-forwarded-host": "evil.example.com", + "x-forwarded-proto": "https", + }) + ); + + expect(result.ok).toBe(false); + expect(authenticateApiKeyFromHeaders).not.toHaveBeenCalled(); + if (!result.ok) { + expect(result.response.status).toBe(403); + } + }); + + test("allows the configured public origin", async () => { + vi.mocked(authenticateApiKeyFromHeaders).mockResolvedValue(apiKeyAuth); + + const result = await authenticateMcpRequest( + createRequest("http://internal.local/api/mcp", { + origin: "https://app.example.com", + "x-api-key": "fbk_test", + }) + ); + + expect(result.ok).toBe(true); + }); + + test("returns auth info for a valid API key and rate limits by API key id", async () => { + vi.mocked(authenticateApiKeyFromHeaders).mockResolvedValue(apiKeyAuth); + + const result = await authenticateMcpRequest( + createRequest("http://localhost/api/mcp", { + "x-request-id": "req_1", + "x-api-key": "fbk_test", + }) + ); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.requestId).toBe("req_1"); + expect(result.authInfo.clientId).toBe("key_1"); + expect(result.authInfo.token).toBe("key_1"); + expect(getMcpAuthentication(result.authInfo)).toEqual(apiKeyAuth); + expect(getMcpRequestId(result.authInfo)).toBe("req_1"); + } + expect(applyRateLimit).toHaveBeenCalledWith(expect.objectContaining({ namespace: "api:v3" }), "key_1"); + }); + + test("returns 429 when rate limited", async () => { + vi.mocked(authenticateApiKeyFromHeaders).mockResolvedValue(apiKeyAuth); + vi.mocked(applyRateLimit).mockRejectedValue(new TooManyRequestsError("Too many requests", 30)); + + const result = await authenticateMcpRequest(createRequest()); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.response.status).toBe(429); + expect(result.response.headers.get("Retry-After")).toBe("30"); + } + }); +}); + +describe("handleAuthenticatedMcpRequest", () => { + beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(applyRateLimit).mockResolvedValue(undefined); + }); + + test("attaches MCP auth info and request headers to handler response", async () => { + vi.mocked(authenticateApiKeyFromHeaders).mockResolvedValue(apiKeyAuth); + const handler = vi.fn(async (request: Request & { auth?: unknown }) => { + expect(request.auth).toMatchObject({ + clientId: "key_1", + }); + return Response.json({ ok: true }); + }); + + const response = await handleAuthenticatedMcpRequest( + createRequest("http://localhost/api/mcp", { + "x-request-id": "req_2", + "x-api-key": "fbk_test", + }), + handler + ); + + expect(response.status).toBe(200); + expect(response.headers.get("X-Request-Id")).toBe("req_2"); + expect(response.headers.get("Cache-Control")).toBe("private, no-store"); + expect(await response.json()).toEqual({ ok: true }); + }); +}); diff --git a/apps/web/modules/mcp/auth.ts b/apps/web/modules/mcp/auth.ts new file mode 100644 index 000000000000..eb6fc54c1a3a --- /dev/null +++ b/apps/web/modules/mcp/auth.ts @@ -0,0 +1,209 @@ +import "server-only"; +import type { AuthInfo } from "@modelcontextprotocol/sdk/server/auth/types.js"; +import { ApiKeyPermission } from "@prisma/client"; +import type { NextRequest } from "next/server"; +import { logger } from "@formbricks/logger"; +import type { TAuthenticationApiKey } from "@formbricks/types/auth"; +import { TooManyRequestsError } from "@formbricks/types/errors"; +import { + problemBadRequest, + problemForbidden, + problemInternalError, + problemTooManyRequests, + problemUnauthorized, +} from "@/app/api/v3/lib/response"; +import { getPublicDomain } from "@/lib/getPublicUrl"; +import { authenticateApiKeyFromHeaders } from "@/modules/api/lib/api-key-auth"; +import { applyRateLimit } from "@/modules/core/rate-limit/helpers"; +import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs"; + +const QUERY_CREDENTIAL_PARAMS = new Set([ + "api_key", + "apikey", + "x-api-key", + "access_token", + "token", + "authorization", +]); + +export type TMcpAuthInfo = AuthInfo & { + extra: { + formbricksAuthentication: TAuthenticationApiKey; + requestId: string; + }; +}; + +type TMcpAuthenticationResult = + | { + ok: true; + authInfo: TMcpAuthInfo; + requestId: string; + } + | { + ok: false; + response: Response; + requestId: string; + }; + +function getRequestId(request: NextRequest): string { + return request.headers.get("x-request-id") ?? crypto.randomUUID(); +} + +function getPublicOrigin(): string { + return new URL(getPublicDomain()).origin; +} + +function hasQueryCredentials(searchParams: URLSearchParams): boolean { + return Array.from(searchParams.keys()).some((param) => QUERY_CREDENTIAL_PARAMS.has(param.toLowerCase())); +} + +function isOriginAllowed(request: NextRequest): boolean { + const origin = request.headers.get("origin"); + if (!origin) { + return true; + } + + try { + return new URL(origin).origin === getPublicOrigin(); + } catch { + return false; + } +} + +function getMcpScopes(authentication: TAuthenticationApiKey): string[] { + const scopes = new Set(["surveys:read"]); + if ( + authentication.workspacePermissions.some( + (permission) => + permission.permission === ApiKeyPermission.write || permission.permission === ApiKeyPermission.manage + ) + ) { + scopes.add("surveys:write"); + } + + return Array.from(scopes); +} + +function createMcpAuthInfo(authentication: TAuthenticationApiKey, requestId: string): TMcpAuthInfo { + return { + token: authentication.apiKeyId, + clientId: authentication.apiKeyId, + scopes: getMcpScopes(authentication), + extra: { + formbricksAuthentication: authentication, + requestId, + }, + }; +} + +export function getMcpAuthentication(authInfo?: AuthInfo): TAuthenticationApiKey | null { + const authentication = authInfo?.extra?.formbricksAuthentication; + if (!authentication || typeof authentication !== "object" || !("apiKeyId" in authentication)) { + return null; + } + + return authentication as TAuthenticationApiKey; +} + +export function getMcpRequestId(authInfo?: AuthInfo): string { + const requestId = authInfo?.extra?.requestId; + return typeof requestId === "string" && requestId.length > 0 ? requestId : crypto.randomUUID(); +} + +export function withMcpResponseHeaders(response: Response, requestId: string): Response { + const headers = new Headers(response.headers); + headers.set("X-Request-Id", requestId); + headers.set("Cache-Control", "private, no-store"); + + return new Response(response.body, { + status: response.status, + statusText: response.statusText, + headers, + }); +} + +export async function authenticateMcpRequest(request: NextRequest): Promise { + const requestId = getRequestId(request); + const instance = request.nextUrl.pathname; + const log = logger.withContext({ requestId, path: instance, method: request.method }); + + if (hasQueryCredentials(request.nextUrl.searchParams)) { + log.warn({ statusCode: 400 }, "MCP API key supplied in query parameters"); + return { + ok: false, + requestId, + response: problemBadRequest(requestId, "API keys must be sent in headers, not query parameters", { + instance, + invalid_params: [ + { + name: "query", + reason: "Send the API key with x-api-key or Authorization: Bearer.", + }, + ], + }), + }; + } + + if (!isOriginAllowed(request)) { + log.warn({ statusCode: 403, origin: request.headers.get("origin") }, "MCP origin validation failed"); + return { + ok: false, + requestId, + response: problemForbidden(requestId, "Cross-origin MCP requests are not allowed", instance), + }; + } + + try { + const authentication = await authenticateApiKeyFromHeaders(request.headers); + if (!authentication) { + log.warn({ statusCode: 401 }, "MCP API authentication failed"); + return { + ok: false, + requestId, + response: problemUnauthorized(requestId, "API key required", instance), + }; + } + + try { + await applyRateLimit(rateLimitConfigs.api.v3, authentication.apiKeyId); + } catch (error) { + log.warn({ error, statusCode: 429, apiKeyId: authentication.apiKeyId }, "MCP API rate limit exceeded"); + return { + ok: false, + requestId, + response: problemTooManyRequests( + requestId, + error instanceof Error ? error.message : "Rate limit exceeded", + error instanceof TooManyRequestsError ? error.retryAfter : undefined + ), + }; + } + + return { + ok: true, + requestId, + authInfo: createMcpAuthInfo(authentication, requestId), + }; + } catch (error) { + log.error({ error, statusCode: 500 }, "MCP API authentication unexpected error"); + return { + ok: false, + requestId, + response: problemInternalError(requestId, "An unexpected error occurred.", instance), + }; + } +} + +export async function handleAuthenticatedMcpRequest( + request: NextRequest, + handler: (request: Request) => Promise +): Promise { + const authResult = await authenticateMcpRequest(request); + if (!authResult.ok) { + return authResult.response; + } + + (request as Request & { auth?: AuthInfo }).auth = authResult.authInfo; + const response = await handler(request); + return withMcpResponseHeaders(response, authResult.requestId); +} diff --git a/apps/web/modules/mcp/constants.ts b/apps/web/modules/mcp/constants.ts new file mode 100644 index 000000000000..cdd85abbcb0c --- /dev/null +++ b/apps/web/modules/mcp/constants.ts @@ -0,0 +1,3 @@ +export const MCP_API_ROUTE = "/api/mcp" as const; +export const MCP_SERVER_NAME = "formbricks-v3-surveys" as const; +export const MCP_SERVER_VERSION = "0.1.0" as const; diff --git a/apps/web/modules/mcp/errors.ts b/apps/web/modules/mcp/errors.ts new file mode 100644 index 000000000000..6afec836c682 --- /dev/null +++ b/apps/web/modules/mcp/errors.ts @@ -0,0 +1,72 @@ +import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import type { ProblemBody } from "@/app/api/v3/lib/response"; + +type TMcpSuccessPayload = Record & { + requestId: string; +}; + +type TMcpErrorPayload = { + error: { + status: number; + title: string; + detail: string; + requestId: string; + code?: string; + invalid_params?: ProblemBody["invalid_params"]; + }; +}; + +function toTextResult(payload: TMcpSuccessPayload | TMcpErrorPayload, isError = false): CallToolResult { + return { + ...(isError ? { isError: true } : {}), + structuredContent: payload, + content: [ + { + type: "text", + text: JSON.stringify(payload), + }, + ], + }; +} + +async function readJsonResponse(response: Response): Promise> { + try { + const body = await response.json(); + return body && typeof body === "object" ? (body as Record) : {}; + } catch { + return {}; + } +} + +export async function responseToMcpToolResult( + response: Response, + fallbackRequestId: string +): Promise { + const body = await readJsonResponse(response); + const requestId = + typeof body.requestId === "string" + ? body.requestId + : (response.headers.get("X-Request-Id") ?? fallbackRequestId); + + if (response.ok) { + return toTextResult({ + ...body, + requestId, + }); + } + + const problem = body as Partial; + return toTextResult( + { + error: { + status: response.status, + title: typeof problem.title === "string" ? problem.title : "Error", + detail: typeof problem.detail === "string" ? problem.detail : response.statusText || "Request failed", + requestId, + ...(typeof problem.code === "string" ? { code: problem.code } : {}), + ...(Array.isArray(problem.invalid_params) ? { invalid_params: problem.invalid_params } : {}), + }, + }, + true + ); +} diff --git a/apps/web/modules/mcp/server.ts b/apps/web/modules/mcp/server.ts new file mode 100644 index 000000000000..c30b1bc8858b --- /dev/null +++ b/apps/web/modules/mcp/server.ts @@ -0,0 +1,23 @@ +import "server-only"; +import { createMcpHandler } from "mcp-handler"; +import { MCP_SERVER_NAME, MCP_SERVER_VERSION } from "./constants"; +import { registerSurveyTools } from "./tools/surveys"; + +export const mcpHandler = createMcpHandler( + (server) => { + registerSurveyTools(server); + }, + { + serverInfo: { + name: MCP_SERVER_NAME, + version: MCP_SERVER_VERSION, + }, + }, + { + basePath: "/api", + disableSse: true, + maxDuration: 60, + sessionIdGenerator: undefined, + verboseLogs: false, + } +); diff --git a/apps/web/modules/mcp/tools/schemas.ts b/apps/web/modules/mcp/tools/schemas.ts new file mode 100644 index 000000000000..ad6858e4804e --- /dev/null +++ b/apps/web/modules/mcp/tools/schemas.ts @@ -0,0 +1,90 @@ +import { z } from "zod"; +import type { TV3CreateSurveyBody, TV3SurveyValidationRequestBody } from "@/app/api/v3/surveys/schemas"; +import { ZV3CreateSurveyBody, ZV3SurveyValidationRequestBody } from "@/app/api/v3/surveys/schemas"; +import { ZId } from "@formbricks/types/common"; +import { ZSurveyFilters, ZSurveyStatus, ZSurveyType } from "@formbricks/types/surveys/types"; + +export const ZMcpListSurveysInput = z.object({ + workspaceId: ZId.describe("Workspace ID whose surveys should be listed."), + limit: z + .number() + .int() + .min(1) + .max(100) + .describe("Maximum number of surveys to return. Defaults to 20.") + .default(20), + cursor: z + .string() + .min(1) + .optional() + .describe("Opaque pagination cursor from a previous list_surveys response."), + includeTotalCount: z + .boolean() + .describe("Whether to include the total matching survey count in the response metadata. Defaults to true.") + .default(true), + filter: z + .object({ + name: z + .object({ + contains: z.string().max(512).optional().describe("Case-insensitive survey name substring."), + }) + .describe("Filter by survey name.") + .optional(), + status: z + .object({ + in: z + .array(ZSurveyStatus) + .optional() + .describe("Survey statuses to include, for example draft or inProgress."), + }) + .describe("Filter by survey status.") + .optional(), + type: z + .object({ + in: z.array(ZSurveyType).optional().describe("Survey types to include, for example link."), + }) + .describe("Filter by survey type.") + .optional(), + }) + .describe("Optional supported v3 survey filters.") + .optional(), + sortBy: ZSurveyFilters.shape.sortBy + .optional() + .describe("Sort field for pagination. Defaults to the v3 API default of updatedAt."), +}); + +export const ZMcpGetSurveyInput = z.object({ + surveyId: z.cuid2().describe("Survey ID to fetch."), + lang: z + .array(z.string().trim().min(1)) + .optional() + .describe("Optional language codes or configured aliases used to filter translatable survey fields."), +}); + +export const ZMcpCreateSurveyInput = ZV3CreateSurveyBody.describe( + "Create a block-based link survey using the v3 survey document contract." +); + +export const ZMcpPatchSurveyInput = z.object({ + surveyId: z.cuid2().describe("Survey ID to update."), + data: z + .record(z.string(), z.unknown()) + .describe( + "Strict top-level v3 survey patch payload. Omitted top-level fields are preserved; provided objects and arrays replace that whole subtree." + ), +}); + +export const ZMcpValidateSurveyInput = ZV3SurveyValidationRequestBody.describe( + "Validate a v3 survey create or patch payload without writing survey changes." +); + +export const ZMcpDeleteSurveyInput = z.object({ + surveyId: z.cuid2().describe("Survey ID to delete."), +}); + +export type TMcpListSurveysInput = z.infer; +export type TMcpGetSurveyInput = z.infer; +export type TMcpCreateSurveyInput = TV3CreateSurveyBody; +export type TMcpPatchSurveyInput = z.infer; +export type TMcpValidateSurveyInput = TV3SurveyValidationRequestBody; +export type TMcpDeleteSurveyInput = z.infer; diff --git a/apps/web/modules/mcp/tools/surveys.test.ts b/apps/web/modules/mcp/tools/surveys.test.ts new file mode 100644 index 000000000000..3c81cc5f7720 --- /dev/null +++ b/apps/web/modules/mcp/tools/surveys.test.ts @@ -0,0 +1,430 @@ +import { ApiKeyPermission } from "@prisma/client"; +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { buildV3AuditLog, queueV3AuditLog } from "@/app/api/v3/lib/audit"; +import { + createdResponse, + noContentResponse, + problemBadRequest, + problemForbidden, + successListResponse, + successResponse, +} from "@/app/api/v3/lib/response"; +import { + createV3SurveyResponse, + deleteV3Survey, + getV3Survey, + listV3Surveys, + patchV3SurveyResponse, + validateV3Survey, +} from "@/app/api/v3/surveys/lib/operations"; +import { buildListSurveysSearchParams, registerSurveyTools } from "./surveys"; + +vi.mock("@/app/api/v3/surveys/lib/operations", () => ({ + createV3SurveyResponse: vi.fn(), + deleteV3Survey: vi.fn(), + getV3Survey: vi.fn(), + listV3Surveys: vi.fn(), + patchV3SurveyResponse: vi.fn(), + validateV3Survey: vi.fn(), +})); + +vi.mock("@/app/api/v3/lib/audit", () => ({ + buildV3AuditLog: vi.fn(), + queueV3AuditLog: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@formbricks/logger", () => ({ + logger: { + withContext: vi.fn(() => ({ + error: vi.fn(), + warn: vi.fn(), + })), + }, +})); + +const apiKeyAuth = { + type: "apiKey" as const, + apiKeyId: "key_1", + organizationId: "org_1", + organizationAccess: { + accessControl: { read: true, write: true }, + }, + workspacePermissions: [ + { + workspaceId: "clxx1234567890123456789012", + workspaceName: "Workspace", + permission: ApiKeyPermission.write, + }, + ], +}; + +const authInfo = { + token: "key_1", + clientId: "key_1", + scopes: ["surveys:read", "surveys:write"], + extra: { + formbricksAuthentication: apiKeyAuth, + requestId: "req_tool", + }, +}; + +function createToolServer() { + const tools = new Map< + string, + { + config: Record; + handler: (input: any, extra: any) => Promise; + } + >(); + const server = { + registerTool: vi.fn((name: string, config: Record, handler: any) => { + tools.set(name, { config, handler }); + }), + }; + + registerSurveyTools(server as any); + return { server, tools }; +} + +describe("buildListSurveysSearchParams", () => { + test("applies defensive defaults when optional defaults are not materialized", () => { + const params = buildListSurveysSearchParams({ + workspaceId: "clxx1234567890123456789012", + } as unknown as Parameters[0]); + + expect(params.get("limit")).toBe("20"); + expect(params.has("includeTotalCount")).toBe(false); + }); + + test("maps structured MCP filters to v3 query parameters", () => { + const params = buildListSurveysSearchParams({ + workspaceId: "clxx1234567890123456789012", + limit: 50, + cursor: "cursor_1", + includeTotalCount: false, + sortBy: "updatedAt", + filter: { + name: { contains: "Onboarding" }, + status: { in: ["draft", "inProgress"] }, + type: { in: ["link"] }, + }, + }); + + expect(params.get("workspaceId")).toBe("clxx1234567890123456789012"); + expect(params.get("limit")).toBe("50"); + expect(params.get("cursor")).toBe("cursor_1"); + expect(params.get("includeTotalCount")).toBe("false"); + expect(params.get("sortBy")).toBe("updatedAt"); + expect(params.get("filter[name][contains]")).toBe("Onboarding"); + expect(params.getAll("filter[status][in]")).toEqual(["draft", "inProgress"]); + expect(params.getAll("filter[type][in]")).toEqual(["link"]); + }); +}); + +describe("registerSurveyTools", () => { + beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(queueV3AuditLog).mockResolvedValue(undefined); + }); + + test("registers survey tools with planning annotations", () => { + const { server, tools } = createToolServer(); + + expect(server.registerTool).toHaveBeenCalledTimes(6); + expect(tools.get("list_surveys")?.config).toMatchObject({ + title: "List surveys", + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + }, + }); + expect(tools.get("get_survey")?.config).toMatchObject({ + title: "Get survey", + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + }, + }); + expect(tools.get("create_survey")?.config).toMatchObject({ + title: "Create survey", + annotations: { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + }, + }); + expect(tools.get("validate_survey")?.config).toMatchObject({ + title: "Validate survey", + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + }, + }); + expect(tools.get("patch_survey")?.config).toMatchObject({ + title: "Patch survey", + annotations: { + readOnlyHint: false, + destructiveHint: true, + idempotentHint: false, + }, + }); + expect(tools.get("delete_survey")?.config).toMatchObject({ + title: "Delete survey", + annotations: { + readOnlyHint: false, + destructiveHint: true, + idempotentHint: false, + }, + }); + }); + + test("list_surveys calls the shared v3 list operation and returns structured content", async () => { + const { tools } = createToolServer(); + vi.mocked(listV3Surveys).mockResolvedValue( + successListResponse( + [{ id: "survey_1" }], + { limit: 20, nextCursor: null, totalCount: 1 }, + { requestId: "req_tool" } + ) + ); + + const result = await tools.get("list_surveys")!.handler( + { + workspaceId: "clxx1234567890123456789012", + limit: 20, + includeTotalCount: true, + }, + { authInfo } + ); + + expect(listV3Surveys).toHaveBeenCalledWith( + expect.objectContaining({ + authentication: apiKeyAuth, + requestId: "req_tool", + instance: "/api/mcp", + }) + ); + expect(result.structuredContent).toEqual({ + data: [{ id: "survey_1" }], + meta: { limit: 20, nextCursor: null, totalCount: 1 }, + requestId: "req_tool", + }); + }); + + test("list_surveys maps v3 problem responses to MCP tool errors", async () => { + const { tools } = createToolServer(); + vi.mocked(listV3Surveys).mockResolvedValue( + problemBadRequest("req_bad", "Invalid query parameters", { + instance: "/api/mcp", + invalid_params: [{ name: "limit", reason: "Too big" }], + }) + ); + + const result = await tools.get("list_surveys")!.handler( + { + workspaceId: "clxx1234567890123456789012", + limit: 101, + includeTotalCount: true, + }, + { authInfo } + ); + + expect(result.isError).toBe(true); + expect(result.structuredContent.error).toMatchObject({ + status: 400, + code: "bad_request", + requestId: "req_bad", + invalid_params: [{ name: "limit", reason: "Too big" }], + }); + }); + + test("get_survey calls the shared v3 get operation", async () => { + const { tools } = createToolServer(); + vi.mocked(getV3Survey).mockResolvedValue( + successResponse({ id: "clxx1234567890123456789012" }, { requestId: "req_tool" }) + ); + + const result = await tools.get("get_survey")!.handler( + { + surveyId: "clxx1234567890123456789012", + lang: ["en-US"], + }, + { authInfo } + ); + + expect(getV3Survey).toHaveBeenCalledWith({ + surveyId: "clxx1234567890123456789012", + lang: ["en-US"], + authentication: apiKeyAuth, + requestId: "req_tool", + instance: "/api/mcp", + }); + expect(result.structuredContent).toEqual({ + data: { id: "clxx1234567890123456789012" }, + requestId: "req_tool", + }); + }); + + test("create_survey queues a successful audit log", async () => { + const { tools } = createToolServer(); + const auditLog = { status: "failure" }; + const createBody = { + workspaceId: "clxx1234567890123456789012", + name: "New survey", + type: "link", + status: "draft", + metadata: {}, + defaultLanguage: "en-US", + languages: [], + welcomeCard: { enabled: false }, + blocks: [], + endings: [], + hiddenFields: { enabled: false, fieldIds: [] }, + variables: [], + }; + vi.mocked(buildV3AuditLog).mockReturnValue(auditLog as any); + vi.mocked(createV3SurveyResponse).mockResolvedValue( + createdResponse({ id: "clxx1234567890123456789012" }, { requestId: "req_tool", location: "/survey" }) + ); + + const result = await tools.get("create_survey")!.handler(createBody, { authInfo }); + + expect(buildV3AuditLog).toHaveBeenCalledWith(apiKeyAuth, "created", "survey", "/api/mcp"); + expect(createV3SurveyResponse).toHaveBeenCalledWith({ + body: createBody, + authentication: apiKeyAuth, + requestId: "req_tool", + instance: "/api/mcp", + auditLog, + }); + expect(auditLog.status).toBe("success"); + expect(queueV3AuditLog).toHaveBeenCalledWith(auditLog, "req_tool", expect.any(Object)); + expect(result.structuredContent).toEqual({ + data: { id: "clxx1234567890123456789012" }, + requestId: "req_tool", + }); + }); + + test("validate_survey calls the shared v3 validation operation without audit logging", async () => { + const { tools } = createToolServer(); + const validationBody = { + operation: "create" as const, + data: { + workspaceId: "clxx1234567890123456789012", + name: "New survey", + }, + }; + vi.mocked(validateV3Survey).mockResolvedValue( + successResponse({ valid: true, operation: "create", invalid_params: [] }, { requestId: "req_tool" }) + ); + + const result = await tools.get("validate_survey")!.handler(validationBody, { authInfo }); + + expect(validateV3Survey).toHaveBeenCalledWith({ + body: validationBody, + authentication: apiKeyAuth, + requestId: "req_tool", + instance: "/api/mcp", + }); + expect(buildV3AuditLog).not.toHaveBeenCalled(); + expect(queueV3AuditLog).not.toHaveBeenCalled(); + expect(result.structuredContent).toEqual({ + data: { valid: true, operation: "create", invalid_params: [] }, + requestId: "req_tool", + }); + }); + + test("patch_survey queues a successful audit log", async () => { + const { tools } = createToolServer(); + const auditLog = { status: "failure" }; + const patchInput = { + surveyId: "clxx1234567890123456789012", + data: { + name: "Updated survey", + }, + }; + vi.mocked(buildV3AuditLog).mockReturnValue(auditLog as any); + vi.mocked(patchV3SurveyResponse).mockResolvedValue( + successResponse({ id: "clxx1234567890123456789012", name: "Updated survey" }, { requestId: "req_tool" }) + ); + + const result = await tools.get("patch_survey")!.handler(patchInput, { authInfo }); + + expect(buildV3AuditLog).toHaveBeenCalledWith(apiKeyAuth, "updated", "survey", "/api/mcp"); + expect(patchV3SurveyResponse).toHaveBeenCalledWith({ + surveyId: "clxx1234567890123456789012", + body: { + name: "Updated survey", + }, + authentication: apiKeyAuth, + requestId: "req_tool", + instance: "/api/mcp", + auditLog, + }); + expect(auditLog.status).toBe("success"); + expect(queueV3AuditLog).toHaveBeenCalledWith(auditLog, "req_tool", expect.any(Object)); + expect(result.structuredContent).toEqual({ + data: { id: "clxx1234567890123456789012", name: "Updated survey" }, + requestId: "req_tool", + }); + }); + + test("delete_survey queues a successful audit log", async () => { + const { tools } = createToolServer(); + const auditLog = { status: "failure" }; + vi.mocked(buildV3AuditLog).mockReturnValue(auditLog as any); + vi.mocked(deleteV3Survey).mockResolvedValue(noContentResponse({ requestId: "req_tool" })); + + const result = await tools.get("delete_survey")!.handler( + { + surveyId: "clxx1234567890123456789012", + }, + { authInfo } + ); + + expect(deleteV3Survey).toHaveBeenCalledWith({ + surveyId: "clxx1234567890123456789012", + authentication: apiKeyAuth, + requestId: "req_tool", + instance: "/api/mcp", + auditLog, + }); + expect(auditLog.status).toBe("success"); + expect(queueV3AuditLog).toHaveBeenCalledWith(auditLog, "req_tool", expect.any(Object)); + expect(result.structuredContent).toEqual({ + requestId: "req_tool", + }); + }); + + test("delete_survey preserves forbidden errors without leaking resource existence", async () => { + const { tools } = createToolServer(); + const auditLog = { status: "failure" }; + vi.mocked(buildV3AuditLog).mockReturnValue(auditLog as any); + vi.mocked(deleteV3Survey).mockResolvedValue( + problemForbidden("req_forbidden", "You are not authorized to access this resource", "/api/mcp") + ); + + const result = await tools.get("delete_survey")!.handler( + { + surveyId: "clxx1234567890123456789012", + }, + { authInfo } + ); + + expect(result.isError).toBe(true); + expect(result.structuredContent.error).toMatchObject({ + status: 403, + code: "forbidden", + detail: "You are not authorized to access this resource", + requestId: "req_forbidden", + }); + expect(auditLog).toMatchObject({ + status: "failure", + eventId: "req_tool", + }); + expect(queueV3AuditLog).toHaveBeenCalledWith(auditLog, "req_tool", expect.any(Object)); + }); +}); diff --git a/apps/web/modules/mcp/tools/surveys.ts b/apps/web/modules/mcp/tools/surveys.ts new file mode 100644 index 000000000000..ea9c408e1fbc --- /dev/null +++ b/apps/web/modules/mcp/tools/surveys.ts @@ -0,0 +1,293 @@ +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { logger } from "@formbricks/logger"; +import { buildV3AuditLog, queueV3AuditLog } from "@/app/api/v3/lib/audit"; +import { + createV3SurveyResponse, + deleteV3Survey, + getV3Survey, + listV3Surveys, + patchV3SurveyResponse, + validateV3Survey, +} from "@/app/api/v3/surveys/lib/operations"; +import { MCP_API_ROUTE } from "@/modules/mcp/constants"; +import { getMcpAuthentication, getMcpRequestId } from "../auth"; +import { responseToMcpToolResult } from "../errors"; +import { + type TMcpCreateSurveyInput, + type TMcpDeleteSurveyInput, + type TMcpGetSurveyInput, + type TMcpListSurveysInput, + type TMcpPatchSurveyInput, + type TMcpValidateSurveyInput, + ZMcpCreateSurveyInput, + ZMcpDeleteSurveyInput, + ZMcpGetSurveyInput, + ZMcpListSurveysInput, + ZMcpPatchSurveyInput, + ZMcpValidateSurveyInput, +} from "./schemas"; + +export function buildListSurveysSearchParams(input: TMcpListSurveysInput): URLSearchParams { + const searchParams = new URLSearchParams(); + + searchParams.set("workspaceId", input.workspaceId); + searchParams.set("limit", String(input.limit ?? 20)); + + if (input.cursor) { + searchParams.set("cursor", input.cursor); + } + + if ((input.includeTotalCount ?? true) === false) { + searchParams.set("includeTotalCount", "false"); + } + + if (input.sortBy) { + searchParams.set("sortBy", input.sortBy); + } + + if (input.filter?.name?.contains) { + searchParams.set("filter[name][contains]", input.filter.name.contains); + } + + input.filter?.status?.in?.forEach((status) => { + searchParams.append("filter[status][in]", status); + }); + + input.filter?.type?.in?.forEach((type) => { + searchParams.append("filter[type][in]", type); + }); + + return searchParams; +} + +export function registerSurveyTools(server: McpServer): void { + server.registerTool( + "list_surveys", + { + title: "List surveys", + description: "List surveys in a Formbricks workspace using the v3 Surveys API contract.", + inputSchema: ZMcpListSurveysInput.shape, + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: true, + }, + }, + async (input: TMcpListSurveysInput, extra) => { + const requestId = getMcpRequestId(extra.authInfo); + const response = await listV3Surveys({ + searchParams: buildListSurveysSearchParams(input), + authentication: getMcpAuthentication(extra.authInfo), + requestId, + instance: MCP_API_ROUTE, + }); + + return await responseToMcpToolResult(response, requestId); + } + ); + + server.registerTool( + "get_survey", + { + title: "Get survey", + description: "Get one Formbricks survey using the v3 Surveys API contract.", + inputSchema: ZMcpGetSurveyInput.shape, + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: true, + }, + }, + async (input: TMcpGetSurveyInput, extra) => { + const requestId = getMcpRequestId(extra.authInfo); + const response = await getV3Survey({ + surveyId: input.surveyId, + lang: input.lang, + authentication: getMcpAuthentication(extra.authInfo), + requestId, + instance: MCP_API_ROUTE, + }); + + return await responseToMcpToolResult(response, requestId); + } + ); + + server.registerTool( + "create_survey", + { + title: "Create survey", + description: "Create a Formbricks link survey using the v3 Surveys API contract.", + inputSchema: ZMcpCreateSurveyInput, + annotations: { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + openWorldHint: true, + }, + }, + async (input: TMcpCreateSurveyInput, extra) => { + const requestId = getMcpRequestId(extra.authInfo); + const authentication = getMcpAuthentication(extra.authInfo); + const log = logger.withContext({ requestId, workspaceId: input.workspaceId }); + const auditLog = buildV3AuditLog(authentication, "created", "survey", MCP_API_ROUTE); + + try { + const response = await createV3SurveyResponse({ + body: input, + authentication, + requestId, + instance: MCP_API_ROUTE, + auditLog, + }); + + if (auditLog) { + if (response.ok) { + auditLog.status = "success"; + } else { + auditLog.eventId = requestId; + } + } + + await queueV3AuditLog(auditLog, requestId, log); + return await responseToMcpToolResult(response, requestId); + } catch (error) { + if (auditLog) { + auditLog.eventId = requestId; + await queueV3AuditLog(auditLog, requestId, log); + } + + throw error; + } + } + ); + + server.registerTool( + "validate_survey", + { + title: "Validate survey", + description: "Validate a v3 survey create or patch payload without writing survey changes.", + inputSchema: ZMcpValidateSurveyInput, + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: true, + }, + }, + async (input: TMcpValidateSurveyInput, extra) => { + const requestId = getMcpRequestId(extra.authInfo); + const response = await validateV3Survey({ + body: input, + authentication: getMcpAuthentication(extra.authInfo), + requestId, + instance: MCP_API_ROUTE, + }); + + return await responseToMcpToolResult(response, requestId); + } + ); + + server.registerTool( + "patch_survey", + { + title: "Patch survey", + description: [ + "Update a Formbricks survey using the v3 Surveys API patch contract.", + "Provided top-level arrays and objects replace that whole subtree.", + ].join(" "), + inputSchema: ZMcpPatchSurveyInput.shape, + annotations: { + readOnlyHint: false, + destructiveHint: true, + idempotentHint: false, + openWorldHint: true, + }, + }, + async (input: TMcpPatchSurveyInput, extra) => { + const requestId = getMcpRequestId(extra.authInfo); + const authentication = getMcpAuthentication(extra.authInfo); + const log = logger.withContext({ requestId, surveyId: input.surveyId }); + const auditLog = buildV3AuditLog(authentication, "updated", "survey", MCP_API_ROUTE); + + try { + const response = await patchV3SurveyResponse({ + surveyId: input.surveyId, + body: input.data, + authentication, + requestId, + instance: MCP_API_ROUTE, + auditLog, + }); + + if (auditLog) { + if (response.ok) { + auditLog.status = "success"; + } else { + auditLog.eventId = requestId; + } + } + + await queueV3AuditLog(auditLog, requestId, log); + return await responseToMcpToolResult(response, requestId); + } catch (error) { + if (auditLog) { + auditLog.eventId = requestId; + await queueV3AuditLog(auditLog, requestId, log); + } + + throw error; + } + } + ); + + server.registerTool( + "delete_survey", + { + title: "Delete survey", + description: "Delete a Formbricks survey using the v3 Surveys API contract.", + inputSchema: ZMcpDeleteSurveyInput.shape, + annotations: { + readOnlyHint: false, + destructiveHint: true, + idempotentHint: false, + openWorldHint: true, + }, + }, + async (input: TMcpDeleteSurveyInput, extra) => { + const requestId = getMcpRequestId(extra.authInfo); + const authentication = getMcpAuthentication(extra.authInfo); + const log = logger.withContext({ requestId, surveyId: input.surveyId }); + const auditLog = buildV3AuditLog(authentication, "deleted", "survey", MCP_API_ROUTE); + + try { + const response = await deleteV3Survey({ + surveyId: input.surveyId, + authentication, + requestId, + instance: MCP_API_ROUTE, + auditLog, + }); + + if (auditLog) { + if (response.ok) { + auditLog.status = "success"; + } else { + auditLog.eventId = requestId; + } + } + + await queueV3AuditLog(auditLog, requestId, log); + return await responseToMcpToolResult(response, requestId); + } catch (error) { + if (auditLog) { + auditLog.eventId = requestId; + await queueV3AuditLog(auditLog, requestId, log); + } + + throw error; + } + } + ); +} diff --git a/apps/web/package.json b/apps/web/package.json index 121720b6ebd9..180c8121e743 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -21,8 +21,8 @@ "i18n:generate": "npx lingo.dev@latest run && npx lingo.dev@latest lockfile --force" }, "dependencies": { - "@cubejs-client/core": "1.6.6", "@boxyhq/saml-jackson": "26.2.0", + "@cubejs-client/core": "1.6.6", "@dnd-kit/core": "6.3.1", "@dnd-kit/modifiers": "9.0.0", "@dnd-kit/sortable": "10.0.0", @@ -33,8 +33,8 @@ "@formbricks/email": "workspace:*", "@formbricks/hub": "0.5.0", "@formbricks/i18n-utils": "workspace:*", - "@formbricks/js-core": "workspace:*", "@formbricks/jobs": "workspace:*", + "@formbricks/js-core": "workspace:*", "@formbricks/logger": "workspace:*", "@formbricks/storage": "workspace:*", "@formbricks/surveys": "workspace:*", @@ -48,6 +48,7 @@ "@lexical/react": "0.41.0", "@lexical/rich-text": "0.41.0", "@lexical/table": "0.41.0", + "@modelcontextprotocol/sdk": "1.26.0", "@next-auth/prisma-adapter": "1.0.7", "@opentelemetry/auto-instrumentations-node": "0.75.0", "@opentelemetry/exporter-metrics-otlp-http": "0.217.0", @@ -100,6 +101,7 @@ "lexical": "0.41.0", "lucide-react": "0.577.0", "markdown-it": "14.1.1", + "mcp-handler": "1.1.0", "next": "16.2.6", "next-auth": "4.24.13", "next-safe-action": "8.1.10", @@ -116,8 +118,8 @@ "react-colorful": "5.6.2", "react-confetti": "6.4.0", "react-day-picker": "9.14.0", - "react-grid-layout": "2.2.2", "react-dom": "19.2.6", + "react-grid-layout": "2.2.2", "react-hook-form": "7.71.2", "react-hot-toast": "2.6.0", "react-i18next": "16.5.8", diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 2c1f9765a168..8e7ccd75f30a 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -321,7 +321,13 @@ services: - ./cube/cube.js:/cube/conf/cube.js:ro - ./cube/schema:/cube/conf/model:ro healthcheck: - test: ["CMD", "wget", "-qO-", "http://localhost:4000/readyz"] + test: + [ + "CMD", + "node", + "-e", + "require('http').get('http://127.0.0.1:4000/readyz', (res) => { res.resume(); process.exit(res.statusCode === 200 ? 0 : 1); }).on('error', () => process.exit(1));", + ] interval: 10s timeout: 5s retries: 12 diff --git a/docs/development/technical-handbook/mcp-server.mdx b/docs/development/technical-handbook/mcp-server.mdx new file mode 100644 index 000000000000..a5dc0d2a543c --- /dev/null +++ b/docs/development/technical-handbook/mcp-server.mdx @@ -0,0 +1,512 @@ +--- +title: "MCP Server" +description: "Configure and use the Formbricks v3 Surveys MCP server" +icon: "bot" +--- + +## Overview + +The Formbricks MCP server exposes a small v3 Surveys tool surface for AI agents. It runs inside the +Formbricks web app at `/api/mcp` and reuses the same v3 Surveys API authentication, authorization, +rate limiting, response handling, and audit logging paths as the REST routes. + + + This page documents the current API-key MVP. OAuth-based MCP authorization is not part of the current + implementation. + + +## Endpoint + +Use the streamable HTTP MCP endpoint on the Formbricks app: + +```text +https://app.formbricks.com/api/mcp +``` + +For local development: + +```text +http://localhost:3000/api/mcp +``` + +The route supports `POST` requests, runs with the Next.js Node.js runtime, and sets private no-store +response headers. Browser-origin MCP requests must come from the configured public Formbricks origin. + +## Authentication + +Authenticate with a Formbricks API key in a request header: + +```http +Authorization: Bearer fbk_... +``` + +or: + +```http +x-api-key: fbk_... +``` + +Do not pass credentials in the query string. The MCP route rejects query credential names such as +`api_key`, `x-api-key`, `access_token`, `token`, and `authorization` case-insensitively. + +API key permissions are enforced through the same workspace access checks as the v3 REST API: + +| Tool | Minimum Workspace Permission | +| ----------------- | ------------------------------------------------------------------- | +| `list_surveys` | `read` | +| `get_survey` | `read` | +| `create_survey` | `write` or `manage` | +| `validate_survey` | `write` or `manage` when the validation request checks write access | +| `patch_survey` | `write` or `manage` | +| `delete_survey` | `write` or `manage` | + + + Store MCP API keys as environment variables or client secrets. Do not commit API keys into MCP client config + files. + + +## Local Setup + +Start Formbricks and prepare the database: + +```bash +pnpm db:up +pnpm db:migrate:dev +pnpm --filter @formbricks/web dev +``` + +Create an API key in the Formbricks app with access to the target workspace. Use the least privileged +permission needed by the agent workflow. + +Verify that the MCP endpoint can list tools: + +```bash +export FORMBRICKS_MCP_API_KEY="fbk_..." + +curl -sS -X POST http://localhost:3000/api/mcp \ + -H "Accept: application/json, text/event-stream" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer $FORMBRICKS_MCP_API_KEY" \ + --data '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}' +``` + +## Codex Configuration + +Store the API key in the shell environment: + +```bash +export FORMBRICKS_MCP_API_KEY="fbk_..." +``` + +For Codex Desktop on macOS, persist the value in the launch environment and restart Codex Desktop: + +```bash +launchctl setenv FORMBRICKS_MCP_API_KEY "fbk_..." +``` + +Register the local MCP server: + +```bash +codex mcp add formbricks-local \ + --url http://localhost:3000/api/mcp \ + --bearer-token-env-var FORMBRICKS_MCP_API_KEY +``` + +Equivalent Codex config: + +```toml +[mcp_servers.formbricks-local] +url = "http://localhost:3000/api/mcp" +bearer_token_env_var = "FORMBRICKS_MCP_API_KEY" +``` + +Verify the registration: + +```bash +codex mcp list +codex mcp get formbricks-local +``` + +Then ask Codex to use the configured server: + +```text +Use the formbricks-local MCP server to list surveys for workspace clxx1234567890123456789012. +``` + +## Claude Configuration + +### Claude Code + +Set the API key: + +```bash +export FORMBRICKS_MCP_API_KEY="fbk_..." +``` + +Add the HTTP MCP server: + +```bash +claude mcp add --transport http formbricks-local http://localhost:3000/api/mcp \ + --header "Authorization: Bearer $FORMBRICKS_MCP_API_KEY" +``` + +Verify it: + +```bash +claude mcp list +claude mcp get formbricks-local +``` + +For a project-shared Claude Code configuration, use `.mcp.json` and keep the API key outside git: + +```json +{ + "mcpServers": { + "formbricks-local": { + "headers": { + "Authorization": "Bearer ${FORMBRICKS_MCP_API_KEY}" + }, + "type": "http", + "url": "http://localhost:3000/api/mcp" + } + } +} +``` + +Claude Code will fail to parse this config if `FORMBRICKS_MCP_API_KEY` is not set. + +### Claude Desktop + +If the installed Claude Desktop build supports remote HTTP MCP server entries, add the same server +definition to the Claude Desktop config file and restart Claude Desktop. + +macOS config path: + +```text +~/Library/Application Support/Claude/claude_desktop_config.json +``` + +Example config: + +```json +{ + "mcpServers": { + "formbricks-local": { + "headers": { + "Authorization": "Bearer ${FORMBRICKS_MCP_API_KEY}" + }, + "type": "http", + "url": "http://localhost:3000/api/mcp" + } + } +} +``` + +Set the API key in the Desktop launch environment before opening Claude Desktop: + +```bash +launchctl setenv FORMBRICKS_MCP_API_KEY "fbk_..." +``` + +If a Claude Desktop version only supports stdio MCP servers, use Claude Code for the HTTP endpoint or +add a local stdio-to-HTTP bridge. + +## Tool Responses + +Tool results include both `structuredContent` and a text content item containing the same JSON string. +Successful results mirror the v3 REST response body and add `requestId` for correlation. + +Errors are returned as MCP error tool results with a structured v3 problem payload: + +```json +{ + "error": { + "code": "bad_request", + "detail": "Invalid survey document", + "invalid_params": [ + { + "name": "blocks.0.elements.0.headline", + "reason": "Required" + } + ], + "requestId": "req_...", + "status": 400, + "title": "Bad Request" + } +} +``` + +## Available Tools + +### list_surveys + +Lists surveys in one workspace. The tool is read-only and idempotent. + +Input: + +```json +{ + "cursor": "opaque-cursor-from-previous-response", + "filter": { + "name": { + "contains": "feedback" + }, + "status": { + "in": ["draft", "inProgress"] + }, + "type": { + "in": ["link"] + } + }, + "includeTotalCount": true, + "limit": 20, + "sortBy": "updatedAt", + "workspaceId": "clxx1234567890123456789012" +} +``` + +Output: + +```json +{ + "data": [ + { + "createdAt": "2026-04-21T10:00:00.000Z", + "creator": { + "name": "Ada Lovelace" + }, + "id": "clsv1234567890123456789012", + "name": "Product Feedback", + "responseCount": 0, + "status": "draft", + "type": "link", + "updatedAt": "2026-04-21T11:00:00.000Z", + "workspaceId": "clxx1234567890123456789012" + } + ], + "meta": { + "limit": 20, + "nextCursor": null, + "totalCount": 1 + }, + "requestId": "req_..." +} +``` + +### get_survey + +Gets one survey by ID. The tool is read-only and idempotent. + +Input: + +```json +{ + "lang": ["en-US"], + "surveyId": "clsv1234567890123456789012" +} +``` + +`lang` is optional. When supplied, it filters translatable survey fields to the requested language +codes or configured aliases. + +### create_survey + +Creates a block-based link survey using the v3 survey document contract. The tool writes data and is +not idempotent. + +Input: + +```json +{ + "blocks": [ + { + "elements": [ + { + "headline": { + "de-DE": "Was sollen wir verbessern?", + "en-US": "What should we improve?" + }, + "id": "satisfaction", + "required": true, + "type": "openText" + } + ], + "name": "Main Block" + } + ], + "defaultLanguage": "en-US", + "endings": [], + "hiddenFields": { + "enabled": false + }, + "languages": [ + { + "code": "de-DE", + "enabled": true + } + ], + "metadata": { + "cx_operation": "enterprise_onboarding", + "title": { + "de-DE": "Produktfeedback", + "en-US": "Product Feedback" + } + }, + "name": "Product Feedback Survey", + "status": "draft", + "variables": [], + "welcomeCard": { + "enabled": true, + "headline": { + "de-DE": "Willkommen", + "en-US": "Welcome" + } + }, + "workspaceId": "clxx1234567890123456789012" +} +``` + +Output uses the same survey resource shape as `GET /api/v3/surveys/{surveyId}` and includes the MCP +`requestId`. + +### validate_survey + +Validates a create or patch payload without writing survey changes. The tool is read-only and +idempotent, but create validation still checks workspace write access when `workspaceId` is present. + +Create validation input: + +```json +{ + "data": { + "blocks": [ + { + "elements": [ + { + "headline": { + "en-US": "What should we improve?" + }, + "id": "satisfaction", + "required": true, + "type": "openText" + } + ], + "name": "Main Block" + } + ], + "defaultLanguage": "en-US", + "name": "Product Feedback Survey", + "workspaceId": "clxx1234567890123456789012" + }, + "operation": "create" +} +``` + +Patch validation input: + +```json +{ + "data": { + "name": "Updated Product Feedback Survey" + }, + "operation": "patch", + "surveyId": "clsv1234567890123456789012" +} +``` + +Validation failures return `200` with `valid: false` and structured `invalid_params`, matching +`POST /api/v3/surveys/validate`. + +### patch_survey + +Updates a survey by ID using the v3 survey patch contract. The tool writes data, can be +destructive, and is not idempotent. Omitted top-level fields are preserved. Provided top-level +objects and arrays replace that whole subtree, so omitted nested entries inside a provided subtree +can be removed. The patch tool does not deep-merge nested objects and does not implement JSON Patch. + + + For agent workflows, fetch the current survey first, modify only the intended top-level fields, run + `validate_survey` with `operation: "patch"`, and then submit the same patch with `patch_survey`. + + +Input: + +```json +{ + "data": { + "metadata": { + "cx_operation": "enterprise_onboarding", + "title": { + "en-US": "Updated Product Feedback" + } + }, + "name": "Updated Product Feedback" + }, + "surveyId": "clsv1234567890123456789012" +} +``` + +Output uses the same survey resource shape as `GET /api/v3/surveys/{surveyId}` and includes the MCP +`requestId`. + +### delete_survey + +Deletes one survey by ID. The tool is destructive, writes data, and is not idempotent. + +Input: + +```json +{ + "surveyId": "clsv1234567890123456789012" +} +``` + +The v3 REST delete operation returns `204 No Content`. The MCP tool result contains the `requestId` +so callers can correlate the audit trail: + +```json +{ + "requestId": "req_..." +} +``` + +## Relationship To V3 Surveys API + +The MCP server does not run custom survey database queries. Each tool calls the shared server-only v3 +survey operations used by the REST routes: + +| MCP Tool | REST Contract | +| ----------------- | ----------------------------------- | +| `list_surveys` | `GET /api/v3/surveys` | +| `get_survey` | `GET /api/v3/surveys/{surveyId}` | +| `create_survey` | `POST /api/v3/surveys` | +| `validate_survey` | `POST /api/v3/surveys/validate` | +| `patch_survey` | `PATCH /api/v3/surveys/{surveyId}` | +| `delete_survey` | `DELETE /api/v3/surveys/{surveyId}` | + +When the v3 OpenAPI contract changes, update the MCP schemas and this page together. The hand-maintained +v3 OpenAPI spec lives at `docs/api-v3-reference/openapi.yml`. + +## Limitations + +- Authentication is API-key only. OAuth-based MCP authorization is a separate follow-up. +- The MCP server exposes only the v3 survey operations listed on this page. +- Tool coverage depends on the current v3 Surveys REST endpoint coverage. +- `create_survey` creates link surveys only. In-app survey creation and distribution settings are not + part of the current v3 create operation. +- `patch_survey` follows the v3 PATCH contract: top-level partial document updates only, no JSON Patch, + and no nested deep merge. Provided top-level objects and arrays replace their whole subtree and can + remove omitted nested entries. +- `validate_survey` validates payload shape and references; it does not create languages, survey + versions, or surveys. +- Query-string credentials are rejected; use headers for API keys. +- Large request bodies are rejected before the MCP handler using the same body-size policy as v3 APIs. + +## Implementation Files + +- `apps/web/app/api/mcp/route.ts` +- `apps/web/modules/mcp/auth.ts` +- `apps/web/modules/mcp/server.ts` +- `apps/web/modules/mcp/tools/surveys.ts` +- `apps/web/modules/mcp/tools/schemas.ts` +- `apps/web/app/api/v3/surveys/lib/operations.ts` diff --git a/docs/docs.json b/docs/docs.json index 64dc44ac6765..be2483e75647 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -338,6 +338,7 @@ "pages": [ "development/technical-handbook/overview", "development/technical-handbook/api-gateway", + "development/technical-handbook/mcp-server", "development/technical-handbook/background-job-processing", "development/technical-handbook/cube-tenant-isolation", "development/technical-handbook/database-model", diff --git a/docs/self-hosting/advanced/migration.mdx b/docs/self-hosting/advanced/migration.mdx index cb6b2d18820c..fe28406ba8c9 100644 --- a/docs/self-hosting/advanced/migration.mdx +++ b/docs/self-hosting/advanced/migration.mdx @@ -243,6 +243,9 @@ Common upgrade issues: - **Missing `CUBEJS_API_SECRET`** (or unreachable Cube endpoint): the Formbricks app fails env validation at boot, or — if env vars are present but Cube is unreachable — dashboards and analysis queries fail while the rest of the app stays healthy +- **Cube healthcheck fails with `wget: not found`**: older v5 Docker Compose files used `wget` for the + bundled Cube healthcheck, but `cubejs/cube:v1.6.6` does not include it. Sync the Cube healthcheck from the + current Docker Compose file so it checks `/readyz` with Node instead. If you need to roll back: diff --git a/packages/survey-ui/src/components/elements/consent.tsx b/packages/survey-ui/src/components/elements/consent.tsx index 1bea01499e13..8be73d37323d 100644 --- a/packages/survey-ui/src/components/elements/consent.tsx +++ b/packages/survey-ui/src/components/elements/consent.tsx @@ -92,10 +92,8 @@ function Consent({ disabled={disabled} aria-invalid={Boolean(errorMessage)} /> - {/* need to use style here because tailwind is not able to use css variables for font size and weight */} {checkboxLabel} diff --git a/packages/survey-ui/src/components/elements/file-upload.tsx b/packages/survey-ui/src/components/elements/file-upload.tsx index d8b895ddd196..30177ab4eab3 100644 --- a/packages/survey-ui/src/components/elements/file-upload.tsx +++ b/packages/survey-ui/src/components/elements/file-upload.tsx @@ -96,8 +96,7 @@ function UploadedFileItem({

{file.name}

@@ -189,8 +188,7 @@ function UploadArea({ aria-label="Upload files by clicking or dragging them here">