Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
221 changes: 221 additions & 0 deletions apps/web/app/api/mcp/route.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>, headers: Record<string, string> = {}): 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<Record<string, any>> {
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",
})
);
});
});
44 changes: 44 additions & 0 deletions apps/web/app/api/mcp/route.ts
Original file line number Diff line number Diff line change
@@ -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<Response> {
const bodySizeResponse = validateMcpBodySize(request);
if (bodySizeResponse) {
return bodySizeResponse;
}

return await handleAuthenticatedMcpRequest(request, mcpHandler);
}
46 changes: 1 addition & 45 deletions apps/web/app/api/v3/lib/api-wrapper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<typeof logger.withContext>
): Promise<void> {
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 = <S extends TV3Schemas | undefined, TProps = unknown>(
params: TWithV3ApiWrapperParams<S, TProps>
): ((req: NextRequest, props: TProps) => Promise<Response>) => {
Expand Down
48 changes: 48 additions & 0 deletions apps/web/app/api/v3/lib/audit.ts
Original file line number Diff line number Diff line change
@@ -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<typeof logger.withContext>
): Promise<void> {
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");
}
}
Loading
Loading