From 8bb26ec36b7dbfa39128922aad44b5b71dea2a60 Mon Sep 17 00:00:00 2001 From: fjunqueira Date: Tue, 24 Feb 2026 20:42:30 -0300 Subject: [PATCH 01/20] feat(01-01): install mongoose and create data layer foundation - Install mongoose ^9.2.2 as dependency - Create src/lib/mongodb.ts: Mongoose connection singleton with HMR guard and graceful defaults-only mode when MONGODB_URI is absent - Create src/lib/encryption.ts: AES-256-GCM encrypt/decrypt with random 96-bit IV, auth tag, and dev key fallback; maskCredential shows last 4 chars --- apps/web/package.json | 1 + apps/web/src/lib/encryption.ts | 79 ++++++++++++++++ apps/web/src/lib/mongodb.ts | 27 ++++++ yarn.lock | 163 ++++++++++++++++++++++++++++++++- 4 files changed, 269 insertions(+), 1 deletion(-) create mode 100644 apps/web/src/lib/encryption.ts create mode 100644 apps/web/src/lib/mongodb.ts diff --git a/apps/web/package.json b/apps/web/package.json index 61d5e732..e9e9231a 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -49,6 +49,7 @@ "langgraph-nextjs-api-passthrough": "^0.1.0", "lodash": "^4.17.21", "lucide-react": "^0.488.0", + "mongoose": "^9.2.2", "next": "15", "next-themes": "^0.4.6", "nuqs": "^2.4.1", diff --git a/apps/web/src/lib/encryption.ts b/apps/web/src/lib/encryption.ts new file mode 100644 index 00000000..ecd599d1 --- /dev/null +++ b/apps/web/src/lib/encryption.ts @@ -0,0 +1,79 @@ +import { + randomBytes, + createCipheriv, + createDecipheriv, +} from "node:crypto"; + +const ALGORITHM = "aes-256-gcm"; +const IV_LENGTH = 12; // 96-bit IV for GCM +const KEY_LENGTH = 32; + +const FALLBACK_KEY = "dev-fallback-key-32-bytes-padded"; + +function getKey(): Buffer { + const envVar = process.env.MCP_ENCRYPTION_KEY; + + if (!envVar) { + console.warn( + "[MCP] MCP_ENCRYPTION_KEY not set — using insecure dev fallback. Set this env var in production.", + ); + return Buffer.from( + FALLBACK_KEY.padEnd(KEY_LENGTH, "0").slice(0, KEY_LENGTH), + ); + } + + const key = Buffer.from(envVar, "hex"); + if (key.length !== KEY_LENGTH) { + throw new Error( + `MCP_ENCRYPTION_KEY must be a 64-char hex string (32 bytes). Got ${key.length} bytes.`, + ); + } + + return key; +} + +export function encrypt(plaintext: string): string { + const key = getKey(); + const iv = randomBytes(IV_LENGTH); + const cipher = createCipheriv(ALGORITHM, key, iv); + + const encrypted = Buffer.concat([ + cipher.update(plaintext, "utf8"), + cipher.final(), + ]); + + const authTag = cipher.getAuthTag(); + + return `${iv.toString("hex")}:${authTag.toString("hex")}:${encrypted.toString("hex")}`; +} + +export function decrypt(stored: string): string { + const key = getKey(); + const parts = stored.split(":"); + + if (parts.length !== 3) { + throw new Error("Invalid encrypted value format. Expected iv:authTag:ciphertext."); + } + + const [ivHex, authTagHex, ciphertextHex] = parts; + const iv = Buffer.from(ivHex, "hex"); + const authTag = Buffer.from(authTagHex, "hex"); + const ciphertext = Buffer.from(ciphertextHex, "hex"); + + const decipher = createDecipheriv(ALGORITHM, key, iv); + decipher.setAuthTag(authTag); + + const decrypted = Buffer.concat([ + decipher.update(ciphertext), + decipher.final(), + ]); + + return decrypted.toString("utf8"); +} + +export function maskCredential(raw: string | null): string | null { + if (!raw || raw.length < 4) { + return null; + } + return `\u2022\u2022\u2022\u2022\u2022\u2022${raw.slice(-4)}`; +} diff --git a/apps/web/src/lib/mongodb.ts b/apps/web/src/lib/mongodb.ts new file mode 100644 index 00000000..445f27ae --- /dev/null +++ b/apps/web/src/lib/mongodb.ts @@ -0,0 +1,27 @@ +import mongoose from "mongoose"; + +declare global { + var __mongoose: { conn: typeof mongoose } | undefined; +} + +export async function connectDB(): Promise { + const MONGODB_URI = process.env.MONGODB_URI; + + if (!MONGODB_URI) { + console.warn( + "[MCP] MONGODB_URI not set — running in defaults-only mode. User-added MCP servers will not be persisted.", + ); + return; + } + + if (global.__mongoose?.conn) { + return; + } + + const conn = await mongoose.connect(MONGODB_URI, { + dbName: "claudia", + bufferCommands: false, + }); + + global.__mongoose = { conn }; +} diff --git a/yarn.lock b/yarn.lock index 1abd9b70..c9c4728e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1617,6 +1617,15 @@ __metadata: languageName: node linkType: hard +"@mongodb-js/saslprep@npm:^1.3.0": + version: 1.4.6 + resolution: "@mongodb-js/saslprep@npm:1.4.6" + dependencies: + sparse-bitfield: ^3.0.3 + checksum: a062ccaa3238317425b16e2ff0d38dbf204c9198b165ca91e3efd9f564e26291de225733bd63e5e6d96b5933706df5aed528b10c2cd046a700174a18f0bbafec + languageName: node + linkType: hard + "@napi-rs/wasm-runtime@npm:^0.2.11": version: 0.2.12 resolution: "@napi-rs/wasm-runtime@npm:0.2.12" @@ -1829,6 +1838,7 @@ __metadata: langgraph-nextjs-api-passthrough: ^0.1.0 lodash: ^4.17.21 lucide-react: ^0.488.0 + mongoose: ^9.2.2 next: 15 next-themes: ^0.4.6 nuqs: ^2.4.1 @@ -4261,6 +4271,22 @@ __metadata: languageName: node linkType: hard +"@types/webidl-conversions@npm:*": + version: 7.0.3 + resolution: "@types/webidl-conversions@npm:7.0.3" + checksum: 535ead9de4d3d6c8e4f4fa14e9db780d2a31e8020debc062f337e1420a41c3265e223e4f4b628f97a11ecf3b96390962cd88a9ffe34f44e159dec583ff49aa34 + languageName: node + linkType: hard + +"@types/whatwg-url@npm:^13.0.0": + version: 13.0.0 + resolution: "@types/whatwg-url@npm:13.0.0" + dependencies: + "@types/webidl-conversions": "*" + checksum: 82018c7dc057dd4b5ee6137e54a659d2d043146eaade8afc2dda472773cc66f2abad73525020a2bf399a09b1bf448504f9e519d6b2d7495e6e781bb5de686753 + languageName: node + linkType: hard + "@types/ws@npm:^8.18.1": version: 8.18.1 resolution: "@types/ws@npm:8.18.1" @@ -5354,6 +5380,13 @@ __metadata: languageName: node linkType: hard +"bson@npm:^7.0.0": + version: 7.2.0 + resolution: "bson@npm:7.2.0" + checksum: a595141c6fb80771d7a5042d05bb73fc875b0448f6be360c88dd8468dad0d75d3511156020f6f22ca2bc7f9a2edc117aa1834b38c816eaea3abd0820238ff0ba + languageName: node + linkType: hard + "buffer-crc32@npm:~0.2.3": version: 0.2.13 resolution: "buffer-crc32@npm:0.2.13" @@ -9211,6 +9244,13 @@ __metadata: languageName: node linkType: hard +"kareem@npm:3.2.0": + version: 3.2.0 + resolution: "kareem@npm:3.2.0" + checksum: db6e61c83600f051cc8a3478a2e50f2c7c281b20c4f4a1ab7deef157e38a8d36eec4e2fee128b82dfc225e991af50b57d5658014a97f8229d8131d8e9ad31e49 + languageName: node + linkType: hard + "katex@npm:^0.16.0, katex@npm:latest": version: 0.16.27 resolution: "katex@npm:0.16.27" @@ -9896,6 +9936,13 @@ __metadata: languageName: node linkType: hard +"memory-pager@npm:^1.0.2": + version: 1.5.0 + resolution: "memory-pager@npm:1.5.0" + checksum: d1a2e684583ef55c61cd3a49101da645b11ad57014dfc565e0b43baa9004b743f7e4ab81493d8fff2ab24e9950987cc3209c94bcc4fc8d7e30a475489a1f15e9 + languageName: node + linkType: hard + "merge-descriptors@npm:1.0.1": version: 1.0.1 resolution: "merge-descriptors@npm:1.0.1" @@ -10634,6 +10681,64 @@ __metadata: languageName: node linkType: hard +"mongodb-connection-string-url@npm:^7.0.0": + version: 7.0.1 + resolution: "mongodb-connection-string-url@npm:7.0.1" + dependencies: + "@types/whatwg-url": ^13.0.0 + whatwg-url: ^14.1.0 + checksum: b1d1fc452e480195f819e0e12af32dc24fbb1737c37411afe8db3aa0da164a4597150a8bd687c31c0fea3ea902b6b694a2599aacb8d8edfe368d5939ac9fab3e + languageName: node + linkType: hard + +"mongodb@npm:~7.0": + version: 7.0.0 + resolution: "mongodb@npm:7.0.0" + dependencies: + "@mongodb-js/saslprep": ^1.3.0 + bson: ^7.0.0 + mongodb-connection-string-url: ^7.0.0 + peerDependencies: + "@aws-sdk/credential-providers": ^3.806.0 + "@mongodb-js/zstd": ^7.0.0 + gcp-metadata: ^7.0.1 + kerberos: ^7.0.0 + mongodb-client-encryption: ">=7.0.0 <7.1.0" + snappy: ^7.3.2 + socks: ^2.8.6 + peerDependenciesMeta: + "@aws-sdk/credential-providers": + optional: true + "@mongodb-js/zstd": + optional: true + gcp-metadata: + optional: true + kerberos: + optional: true + mongodb-client-encryption: + optional: true + snappy: + optional: true + socks: + optional: true + checksum: 835b2a9c71b4e2a7ca4353d59df63e9c54000e32ecb9e7e6e3a1d19d9e5c48841a5dd94779320cc679eacf5ffcc1e3fc790cc9a5fbe32144dc7c3b707fdf7763 + languageName: node + linkType: hard + +"mongoose@npm:^9.2.2": + version: 9.2.2 + resolution: "mongoose@npm:9.2.2" + dependencies: + kareem: 3.2.0 + mongodb: ~7.0 + mpath: 0.9.0 + mquery: 6.0.0 + ms: 2.1.3 + sift: 17.1.3 + checksum: efaba51d4383f617249215eb2cab67d16038d9ebbc9ca851941923bd35544a05cb6f7839e027dd800ca4fd47517eda5fc9ddb7712d9c613ccb173df9db54308f + languageName: node + linkType: hard + "motion-dom@npm:^12.28.1": version: 12.28.1 resolution: "motion-dom@npm:12.28.1" @@ -10650,6 +10755,20 @@ __metadata: languageName: node linkType: hard +"mpath@npm:0.9.0": + version: 0.9.0 + resolution: "mpath@npm:0.9.0" + checksum: 1052f1f926db04502440f76164ae16ed53aa41f3ce34e7e64e3ed451b7d91ede295c3b600801c5f9eb862f03d9d59b7aa5aaf690c341fc521bef025d0f5cd773 + languageName: node + linkType: hard + +"mquery@npm:6.0.0": + version: 6.0.0 + resolution: "mquery@npm:6.0.0" + checksum: de7d8d21d31c03bf5929c44dc926b8f7ac6d4f03669ff2c36cf4fa5dbab35d8e079959e80b72fbf757e668722b4a96b86f7617ef153936927ea2bcde4f08be76 + languageName: node + linkType: hard + "ms@npm:2.0.0": version: 2.0.0 resolution: "ms@npm:2.0.0" @@ -11767,7 +11886,7 @@ __metadata: languageName: node linkType: hard -"punycode@npm:^2.1.0": +"punycode@npm:^2.1.0, punycode@npm:^2.3.1": version: 2.3.1 resolution: "punycode@npm:2.3.1" checksum: bb0a0ceedca4c3c57a9b981b90601579058903c62be23c5e8e843d2c2d4148a3ecf029d5133486fb0e1822b098ba8bba09e89d6b21742d02fa26bda6441a6fb2 @@ -13053,6 +13172,13 @@ __metadata: languageName: node linkType: hard +"sift@npm:17.1.3": + version: 17.1.3 + resolution: "sift@npm:17.1.3" + checksum: 56d09c72720cd75f757dad31fc13cc84461c06c0416d23c1dc05e64276676fa1fecaddb055f0d2aa714d36a93c2acaad8cb2f2ef6d06d8c8bb1af84657de2046 + languageName: node + linkType: hard + "signal-exit@npm:^3.0.2, signal-exit@npm:^3.0.7": version: 3.0.7 resolution: "signal-exit@npm:3.0.7" @@ -13220,6 +13346,15 @@ __metadata: languageName: node linkType: hard +"sparse-bitfield@npm:^3.0.3": + version: 3.0.3 + resolution: "sparse-bitfield@npm:3.0.3" + dependencies: + memory-pager: ^1.0.2 + checksum: 174da88dbbcc783d5dbd26921931cc83830280b8055fb05333786ebe6fc015b9601b24972b3d55920dd2d9f5fb120576fbfa2469b08e5222c9cadf3f05210aab + languageName: node + linkType: hard + "sprintf-js@npm:~1.0.2": version: 1.0.3 resolution: "sprintf-js@npm:1.0.3" @@ -13693,6 +13828,15 @@ __metadata: languageName: node linkType: hard +"tr46@npm:^5.1.0": + version: 5.1.1 + resolution: "tr46@npm:5.1.1" + dependencies: + punycode: ^2.3.1 + checksum: da7a04bd3f77e641abdabe948bb84f24e6ee73e81c8c96c36fe79796c889ba97daf3dbacae778f8581ff60307a4136ee14c9540a5f85ebe44f99c6cc39a97690 + languageName: node + linkType: hard + "tr46@npm:~0.0.3": version: 0.0.3 resolution: "tr46@npm:0.0.3" @@ -14522,6 +14666,23 @@ __metadata: languageName: node linkType: hard +"webidl-conversions@npm:^7.0.0": + version: 7.0.0 + resolution: "webidl-conversions@npm:7.0.0" + checksum: f05588567a2a76428515333eff87200fae6c83c3948a7482ebb109562971e77ef6dc49749afa58abb993391227c5697b3ecca52018793e0cb4620a48f10bd21b + languageName: node + linkType: hard + +"whatwg-url@npm:^14.1.0": + version: 14.2.0 + resolution: "whatwg-url@npm:14.2.0" + dependencies: + tr46: ^5.1.0 + webidl-conversions: ^7.0.0 + checksum: c4f1ae1d353b9e56ab3c154cd73bf2b621cea1a2499fd2a9b2a17d448c2ed5e73a8922a0f395939de565fc3661461140111ae2aea26d4006a1ad0cfbf021c034 + languageName: node + linkType: hard + "whatwg-url@npm:^5.0.0": version: 5.0.0 resolution: "whatwg-url@npm:5.0.0" From 615b66a9e2d1361c0fa4c399dc61c8b14ce731ac Mon Sep 17 00:00:00 2001 From: fjunqueira Date: Tue, 24 Feb 2026 20:43:17 -0300 Subject: [PATCH 02/20] feat(01-01): create Mongoose model and default server assembly - Create src/models/mcp-server.ts: IMcpServer interface + McpServerSchema with collection "mcp", timestamps, HMR guard - Create src/lib/mcp-defaults.ts: getDefaultServers() assembles default servers from MCP_TYPEBOT_URL and MCP_CLOUDHUMANS_URL env vars with stable IDs and isDefault flag --- apps/web/src/lib/mcp-defaults.ts | 45 +++++++++++++++++++++++++++++++ apps/web/src/models/mcp-server.ts | 35 ++++++++++++++++++++++++ 2 files changed, 80 insertions(+) create mode 100644 apps/web/src/lib/mcp-defaults.ts create mode 100644 apps/web/src/models/mcp-server.ts diff --git a/apps/web/src/lib/mcp-defaults.ts b/apps/web/src/lib/mcp-defaults.ts new file mode 100644 index 00000000..4918e56d --- /dev/null +++ b/apps/web/src/lib/mcp-defaults.ts @@ -0,0 +1,45 @@ +export interface McpServerDefault { + id: string; + name: string; + url: string; + authType: "bearer"; + credentials: string | null; + enabled: boolean; + isDefault: true; + createdAt: null; + updatedAt: null; +} + +export function getDefaultServers(): McpServerDefault[] { + const defaults: McpServerDefault[] = []; + + if (process.env.MCP_TYPEBOT_URL) { + defaults.push({ + id: "default-typebot", + name: "Typebot", + url: process.env.MCP_TYPEBOT_URL, + authType: "bearer", + credentials: process.env.MCP_TYPEBOT_BEARER_TOKEN ?? null, + enabled: true, + isDefault: true, + createdAt: null, + updatedAt: null, + }); + } + + if (process.env.MCP_CLOUDHUMANS_URL) { + defaults.push({ + id: "default-cloudhumans", + name: "CloudHumans", + url: process.env.MCP_CLOUDHUMANS_URL, + authType: "bearer", + credentials: process.env.MCP_CLOUDHUMANS_BEARER_TOKEN ?? null, + enabled: true, + isDefault: true, + createdAt: null, + updatedAt: null, + }); + } + + return defaults; +} diff --git a/apps/web/src/models/mcp-server.ts b/apps/web/src/models/mcp-server.ts new file mode 100644 index 00000000..08cf4db6 --- /dev/null +++ b/apps/web/src/models/mcp-server.ts @@ -0,0 +1,35 @@ +import mongoose, { Schema, Document } from "mongoose"; + +export interface IMcpServer extends Document { + name: string; + url: string; + authType: "none" | "bearer" | "apiKey"; + credentials: string | null; + enabled: boolean; + createdAt: Date; + updatedAt: Date; +} + +const McpServerSchema = new Schema( + { + name: { type: String, required: true }, + url: { type: String, required: true }, + authType: { + type: String, + enum: ["none", "bearer", "apiKey"], + required: true, + }, + credentials: { type: String, default: null }, + enabled: { type: Boolean, default: true }, + }, + { + timestamps: true, + collection: "mcp", + }, +); + +const McpServer = + (mongoose.models.McpServer as mongoose.Model) || + mongoose.model("McpServer", McpServerSchema); + +export default McpServer; From 16e8cc3efdecb60b7caec78e12f1b13b31fe0a38 Mon Sep 17 00:00:00 2001 From: fjunqueira Date: Tue, 24 Feb 2026 20:47:43 -0300 Subject: [PATCH 03/20] feat(01-02): add GET and POST handlers for /api/mcp-servers - GET merges defaults (from env vars) with user servers from MongoDB - GET masks all credentials before returning (decrypt then maskCredential) - GET silently falls back to defaults-only when MongoDB is unreachable - POST validates body with Zod schema including auth/credential cross-field rules - POST encrypts credentials before MongoDB write, returns masked on response - POST returns 422 on validation failure, 503 when DB not configured, 500 on error - No Edge Runtime export (Node.js runtime required for MongoDB) --- apps/web/src/app/api/mcp-servers/route.ts | 140 ++++++++++++++++++++++ 1 file changed, 140 insertions(+) create mode 100644 apps/web/src/app/api/mcp-servers/route.ts diff --git a/apps/web/src/app/api/mcp-servers/route.ts b/apps/web/src/app/api/mcp-servers/route.ts new file mode 100644 index 00000000..a6658520 --- /dev/null +++ b/apps/web/src/app/api/mcp-servers/route.ts @@ -0,0 +1,140 @@ +import { NextRequest } from "next/server"; +import mongoose from "mongoose"; +import { z } from "zod"; +import { connectDB } from "@/lib/mongodb"; +import McpServer from "@/models/mcp-server"; +import { getDefaultServers } from "@/lib/mcp-defaults"; +import { encrypt, decrypt, maskCredential } from "@/lib/encryption"; + +const CreateMcpServerSchema = z + .object({ + name: z.string().min(1).max(100), + url: z.string().url(), + authType: z.enum(["none", "bearer", "apiKey"]), + credentials: z.string().min(1).nullable().optional(), + enabled: z.boolean().default(true), + }) + .superRefine((data, ctx) => { + if (data.authType === "bearer" || data.authType === "apiKey") { + if (!data.credentials) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["credentials"], + message: `credentials is required when authType is "${data.authType}"`, + }); + } + } + if (data.authType === "none") { + if (data.credentials != null) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["credentials"], + message: "credentials must be null or omitted when authType is \"none\"", + }); + } + } + }); + +export async function GET() { + const defaults = getDefaultServers().map((server) => ({ + ...server, + credentials: maskCredential(server.credentials), + })); + + const userServers: Array<{ + id: string; + name: string; + url: string; + authType: "none" | "bearer" | "apiKey"; + credentials: string | null; + enabled: boolean; + isDefault: false; + createdAt: Date; + updatedAt: Date; + }> = []; + + try { + await connectDB(); + const docs = await McpServer.find({}).lean(); + + for (const doc of docs) { + userServers.push({ + id: (doc._id as mongoose.Types.ObjectId).toString(), + name: doc.name, + url: doc.url, + authType: doc.authType, + credentials: + doc.credentials != null + ? maskCredential(decrypt(doc.credentials)) + : null, + enabled: doc.enabled, + isDefault: false, + createdAt: doc.createdAt, + updatedAt: doc.updatedAt, + }); + } + } catch (error) { + console.warn( + "[MCP] MongoDB unavailable, returning defaults only:", + error, + ); + } + + return Response.json({ servers: [...defaults, ...userServers] }); +} + +export async function POST(req: NextRequest) { + try { + const body = await req.json(); + const parsed = CreateMcpServerSchema.safeParse(body); + + if (!parsed.success) { + return Response.json( + { error: parsed.error.flatten() }, + { status: 422 }, + ); + } + + await connectDB(); + + if (mongoose.connection.readyState !== 1) { + return Response.json( + { error: "Database not configured" }, + { status: 503 }, + ); + } + + const encryptedCreds = parsed.data.credentials + ? encrypt(parsed.data.credentials) + : null; + + const doc = await McpServer.create({ + ...parsed.data, + credentials: encryptedCreds, + }); + + return Response.json( + { + id: doc._id.toString(), + name: doc.name, + url: doc.url, + authType: doc.authType, + credentials: + doc.credentials != null + ? maskCredential(decrypt(doc.credentials)) + : null, + enabled: doc.enabled, + isDefault: false, + createdAt: doc.createdAt, + updatedAt: doc.updatedAt, + }, + { status: 201 }, + ); + } catch (error) { + console.error("[MCP] Failed to create server:", error); + return Response.json( + { error: "Failed to create server" }, + { status: 500 }, + ); + } +} From 356e205cb0c4f2b806de0784bbb7eebe7cc079f6 Mon Sep 17 00:00:00 2001 From: fjunqueira Date: Tue, 24 Feb 2026 20:48:29 -0300 Subject: [PATCH 04/20] feat(01-02): add PUT and DELETE handlers for /api/mcp-servers/[id] - PUT updates user-added servers with re-encrypted credentials - PUT returns 403 for default server IDs (default-typebot, default-cloudhumans) - PUT returns 422 when credentials contain masked value or are empty for bearer/apiKey - PUT returns 404 for non-existent servers or invalid ObjectId format - DELETE removes user-added servers; returns 403 for default server IDs - DELETE returns 404 for non-existent servers or invalid ObjectId format - ObjectId validation via mongoose.Types.ObjectId.isValid() prevents CastError 500s - No Edge Runtime export (Node.js runtime required for MongoDB) --- .../web/src/app/api/mcp-servers/[id]/route.ts | 179 ++++++++++++++++++ 1 file changed, 179 insertions(+) create mode 100644 apps/web/src/app/api/mcp-servers/[id]/route.ts diff --git a/apps/web/src/app/api/mcp-servers/[id]/route.ts b/apps/web/src/app/api/mcp-servers/[id]/route.ts new file mode 100644 index 00000000..17c73e3d --- /dev/null +++ b/apps/web/src/app/api/mcp-servers/[id]/route.ts @@ -0,0 +1,179 @@ +import { NextRequest } from "next/server"; +import mongoose from "mongoose"; +import { z } from "zod"; +import { connectDB } from "@/lib/mongodb"; +import McpServer from "@/models/mcp-server"; +import { encrypt, decrypt, maskCredential } from "@/lib/encryption"; + +const DEFAULT_SERVER_IDS = ["default-typebot", "default-cloudhumans"]; + +const MASKED_PREFIX = "\u2022\u2022\u2022\u2022\u2022\u2022"; + +const UpdateMcpServerSchema = z + .object({ + name: z.string().min(1).max(100).optional(), + url: z.string().url().optional(), + authType: z.enum(["none", "bearer", "apiKey"]).optional(), + credentials: z.string().min(1).nullable().optional(), + enabled: z.boolean().optional(), + }) + .superRefine((data, ctx) => { + if (data.credentials != null && data.credentials.startsWith(MASKED_PREFIX)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["credentials"], + message: + "Credentials must be submitted in full — masked values are not accepted", + }); + } + if (data.authType === "bearer" || data.authType === "apiKey") { + if (!data.credentials) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["credentials"], + message: `credentials is required when authType is "${data.authType}"`, + }); + } + } + if (data.authType === "none") { + if (data.credentials != null) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["credentials"], + message: + 'credentials must be null or omitted when authType is "none"', + }); + } + } + }); + +export async function PUT( + req: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + try { + const { id } = await params; + + if (DEFAULT_SERVER_IDS.includes(id)) { + return Response.json( + { error: "Default servers cannot be modified" }, + { status: 403 }, + ); + } + + if (!mongoose.Types.ObjectId.isValid(id)) { + return Response.json({ error: "Server not found" }, { status: 404 }); + } + + const body = await req.json(); + const parsed = UpdateMcpServerSchema.safeParse(body); + + if (!parsed.success) { + return Response.json( + { error: parsed.error.flatten() }, + { status: 422 }, + ); + } + + await connectDB(); + + if (mongoose.connection.readyState !== 1) { + return Response.json( + { error: "Database not configured" }, + { status: 503 }, + ); + } + + const updateData: Record = {}; + + if (parsed.data.name !== undefined) updateData.name = parsed.data.name; + if (parsed.data.url !== undefined) updateData.url = parsed.data.url; + if (parsed.data.authType !== undefined) + updateData.authType = parsed.data.authType; + if (parsed.data.enabled !== undefined) + updateData.enabled = parsed.data.enabled; + + if (parsed.data.credentials !== undefined) { + updateData.credentials = + parsed.data.credentials != null + ? encrypt(parsed.data.credentials) + : null; + } + + const updated = await McpServer.findByIdAndUpdate(id, updateData, { + new: true, + runValidators: true, + }).lean(); + + if (!updated) { + return Response.json({ error: "Server not found" }, { status: 404 }); + } + + return Response.json( + { + id: (updated._id as mongoose.Types.ObjectId).toString(), + name: updated.name, + url: updated.url, + authType: updated.authType, + credentials: + updated.credentials != null + ? maskCredential(decrypt(updated.credentials)) + : null, + enabled: updated.enabled, + isDefault: false, + createdAt: updated.createdAt, + updatedAt: updated.updatedAt, + }, + { status: 200 }, + ); + } catch (error) { + console.error("[MCP] Failed to update server:", error); + return Response.json( + { error: "Failed to update server" }, + { status: 500 }, + ); + } +} + +export async function DELETE( + _req: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + try { + const { id } = await params; + + if (DEFAULT_SERVER_IDS.includes(id)) { + return Response.json( + { error: "Default servers cannot be deleted" }, + { status: 403 }, + ); + } + + if (!mongoose.Types.ObjectId.isValid(id)) { + return Response.json({ error: "Server not found" }, { status: 404 }); + } + + await connectDB(); + + if (mongoose.connection.readyState !== 1) { + return Response.json( + { error: "Database not configured" }, + { status: 503 }, + ); + } + + const deleted = await McpServer.findByIdAndDelete(id).lean(); + + if (!deleted) { + return Response.json({ error: "Server not found" }, { status: 404 }); + } + + return Response.json({ success: true }, { status: 200 }); + } catch (error) { + console.error("[MCP] Failed to delete server:", error); + return Response.json( + { error: "Failed to delete server" }, + { status: 500 }, + ); + } +} From 9d4276812c4fd5cbb434b113961a3c4f23a3fbf7 Mon Sep 17 00:00:00 2001 From: fjunqueira Date: Tue, 24 Feb 2026 22:41:42 -0300 Subject: [PATCH 05/20] feat(03-01): create useMcpServers CRUD hook - McpServer interface matching GET /api/mcp-servers response shape - fetchServers with loading/error state via useCallback + useEffect - addServer, updateServer, deleteServer with toast feedback - toggleServer with optimistic update and rollback on failure - refetch exported for external refresh triggers --- .../settings/hooks/use-mcp-servers.tsx | 139 ++++++++++++++++++ 1 file changed, 139 insertions(+) create mode 100644 apps/web/src/features/settings/hooks/use-mcp-servers.tsx diff --git a/apps/web/src/features/settings/hooks/use-mcp-servers.tsx b/apps/web/src/features/settings/hooks/use-mcp-servers.tsx new file mode 100644 index 00000000..af2dd631 --- /dev/null +++ b/apps/web/src/features/settings/hooks/use-mcp-servers.tsx @@ -0,0 +1,139 @@ +"use client"; + +import { useState, useCallback, useEffect } from "react"; +import { toast } from "sonner"; + +export interface McpServer { + id: string; + name: string; + url: string; + authType: "none" | "bearer" | "apiKey"; + credentials: string | null; + enabled: boolean; + isDefault: boolean; + createdAt: string | null; + updatedAt: string | null; +} + +interface UseMcpServersReturn { + servers: McpServer[]; + loading: boolean; + error: string | null; + addServer: (body: Omit) => Promise; + updateServer: (id: string, body: Partial>) => Promise; + deleteServer: (id: string) => Promise; + toggleServer: (id: string, enabled: boolean) => Promise; + refetch: () => Promise; +} + +export function useMcpServers(): UseMcpServersReturn { + const [servers, setServers] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const fetchServers = useCallback(async () => { + setLoading(true); + setError(null); + try { + const res = await fetch("/api/mcp-servers"); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const data = await res.json(); + setServers(data.servers ?? []); + } catch (err) { + console.error("[useMcpServers] Failed to load:", err); + setError("Failed to load MCP servers"); + toast.error("Failed to load MCP servers"); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + fetchServers(); + }, [fetchServers]); + + const addServer = useCallback( + async (body: Omit) => { + try { + const res = await fetch("/api/mcp-servers", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const newServer: McpServer = await res.json(); + setServers((prev) => [...prev, newServer]); + toast.success("Server added"); + } catch (err) { + console.error("[useMcpServers] Failed to add:", err); + toast.error("Failed to add server"); + } + }, + [], + ); + + const updateServer = useCallback( + async (id: string, body: Partial>) => { + try { + const res = await fetch(`/api/mcp-servers/${id}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const updated: McpServer = await res.json(); + setServers((prev) => prev.map((s) => (s.id === id ? updated : s))); + toast.success("Server updated"); + } catch (err) { + console.error("[useMcpServers] Failed to update:", err); + toast.error("Failed to update server"); + } + }, + [], + ); + + const deleteServer = useCallback(async (id: string) => { + try { + const res = await fetch(`/api/mcp-servers/${id}`, { method: "DELETE" }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + setServers((prev) => prev.filter((s) => s.id !== id)); + toast.success("Server deleted"); + } catch (err) { + console.error("[useMcpServers] Failed to delete:", err); + toast.error("Failed to delete server"); + } + }, []); + + const toggleServer = useCallback(async (id: string, enabled: boolean) => { + // Optimistic update + setServers((prev) => + prev.map((s) => (s.id === id ? { ...s, enabled } : s)), + ); + try { + const res = await fetch(`/api/mcp-servers/${id}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ enabled }), + }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + } catch (err) { + // Rollback on failure + console.error("[useMcpServers] Failed to toggle:", err); + setServers((prev) => + prev.map((s) => (s.id === id ? { ...s, enabled: !enabled } : s)), + ); + toast.error("Failed to update server"); + } + }, []); + + return { + servers, + loading, + error, + addServer, + updateServer, + deleteServer, + toggleServer, + refetch: fetchServers, + }; +} From a35f4521cd52b3545be83acb442822ec2422c1ea Mon Sep 17 00:00:00 2001 From: fjunqueira Date: Tue, 24 Feb 2026 22:41:47 -0300 Subject: [PATCH 06/20] feat(03-01): add MCP Servers list UI and integrate into Settings page - McpServerRow: name, URL, Default badge, inline Switch (disabled for defaults) - McpServerList: skeleton loading state, server rows, optional onAdd/renderActions slots - Settings page: new MCP Servers section below API Keys with Separator --- .../mcp-servers/mcp-server-list.tsx | 71 +++++++++++++++++++ .../components/mcp-servers/mcp-server-row.tsx | 45 ++++++++++++ apps/web/src/features/settings/index.tsx | 14 ++++ 3 files changed, 130 insertions(+) create mode 100644 apps/web/src/features/settings/components/mcp-servers/mcp-server-list.tsx create mode 100644 apps/web/src/features/settings/components/mcp-servers/mcp-server-row.tsx diff --git a/apps/web/src/features/settings/components/mcp-servers/mcp-server-list.tsx b/apps/web/src/features/settings/components/mcp-servers/mcp-server-list.tsx new file mode 100644 index 00000000..c670d15a --- /dev/null +++ b/apps/web/src/features/settings/components/mcp-servers/mcp-server-list.tsx @@ -0,0 +1,71 @@ +"use client"; + +import React from "react"; +import { Skeleton } from "@/components/ui/skeleton"; +import { McpServerRow } from "./mcp-server-row"; +import type { McpServer } from "../../hooks/use-mcp-servers"; + +interface McpServerListProps { + servers: McpServer[]; + loading: boolean; + onToggle: (id: string, enabled: boolean) => void; + renderActions?: (server: McpServer) => React.ReactNode; + onAdd?: () => void; +} + +export function McpServerList({ + servers, + loading, + onToggle, + renderActions, + onAdd, +}: McpServerListProps): React.ReactNode { + return ( +
+ {/* Top bar */} +
+

+ Manage MCP server connections for your agents. +

+ {onAdd && ( + + )} +
+ + {/* Loading skeleton */} + {loading ? ( +
+ {[0, 1, 2].map((i) => ( +
+
+ + +
+ +
+ ))} +
+ ) : ( +
+ {servers.map((server) => ( + + ))} +
+ )} +
+ ); +} diff --git a/apps/web/src/features/settings/components/mcp-servers/mcp-server-row.tsx b/apps/web/src/features/settings/components/mcp-servers/mcp-server-row.tsx new file mode 100644 index 00000000..4a25bc84 --- /dev/null +++ b/apps/web/src/features/settings/components/mcp-servers/mcp-server-row.tsx @@ -0,0 +1,45 @@ +"use client"; + +import React from "react"; +import { Badge } from "@/components/ui/badge"; +import { Switch } from "@/components/ui/switch"; +import type { McpServer } from "../../hooks/use-mcp-servers"; + +interface McpServerRowProps { + server: McpServer; + onToggle: (id: string, enabled: boolean) => void; + actions?: React.ReactNode; +} + +export function McpServerRow({ + server, + onToggle, + actions, +}: McpServerRowProps): React.ReactNode { + return ( +
+ {/* Left: name + badge + URL */} +
+
+ {server.name} + {server.isDefault && ( + + Default + + )} +
+ {server.url} +
+ + {/* Right: toggle + actions slot */} +
+ onToggle(server.id, checked)} + disabled={server.isDefault} + /> + {actions} +
+
+ ); +} diff --git a/apps/web/src/features/settings/index.tsx b/apps/web/src/features/settings/index.tsx index b9ba8a5c..ed4dea7f 100644 --- a/apps/web/src/features/settings/index.tsx +++ b/apps/web/src/features/settings/index.tsx @@ -6,11 +6,15 @@ import { Separator } from "@/components/ui/separator"; import { PasswordInput } from "@/components/ui/password-input"; import { Label } from "@/components/ui/label"; import { useLocalStorage } from "@/hooks/use-local-storage"; +import { useMcpServers } from "./hooks/use-mcp-servers"; +import { McpServerList } from "./components/mcp-servers/mcp-server-list"; /** * The Settings interface component containing API Keys configuration. */ export default function SettingsInterface(): React.ReactNode { + const { servers, loading: mcpLoading, toggleServer } = useMcpServers(); + // Use localStorage hooks for each API key const [openaiApiKey, setOpenaiApiKey] = useLocalStorage( "lg:settings:openaiApiKey", @@ -88,6 +92,16 @@ export default function SettingsInterface(): React.ReactNode { + + +
+

MCP Servers

+ +
); } From 2981c24ec9ace612eaab15cfc70cd540e024c4b5 Mon Sep 17 00:00:00 2001 From: fjunqueira Date: Tue, 24 Feb 2026 22:46:15 -0300 Subject: [PATCH 07/20] feat(03-02): create McpServerFormDialog for Add and Edit modes - Shared dialog component with server? prop for add/edit mode discrimination - useState-based form: name, url, authType, credentials (never pre-filled) - Conditional credentials field shown only when authType !== none - handleAuthTypeChange clears credentials when switching to none (Pitfall 5) - handleSubmit omits credentials when empty in edit mode (Pitfall 1 avoided) - credentials: null sent when authType is none to clear stored value - Form reset via useEffect when dialog opens --- .../mcp-servers/mcp-server-form-dialog.tsx | 192 ++++++++++++++++++ 1 file changed, 192 insertions(+) create mode 100644 apps/web/src/features/settings/components/mcp-servers/mcp-server-form-dialog.tsx diff --git a/apps/web/src/features/settings/components/mcp-servers/mcp-server-form-dialog.tsx b/apps/web/src/features/settings/components/mcp-servers/mcp-server-form-dialog.tsx new file mode 100644 index 00000000..1f806fd2 --- /dev/null +++ b/apps/web/src/features/settings/components/mcp-servers/mcp-server-form-dialog.tsx @@ -0,0 +1,192 @@ +"use client"; + +import React, { useState, useEffect } from "react"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import type { McpServer } from "../../hooks/use-mcp-servers"; + +interface McpServerFormDialogProps { + server?: McpServer; + onSave: (body: Record) => Promise; + trigger: React.ReactNode; +} + +export function McpServerFormDialog({ + server, + onSave, + trigger, +}: McpServerFormDialogProps): React.ReactNode { + const [open, setOpen] = useState(false); + const [name, setName] = useState(""); + const [url, setUrl] = useState(""); + const [authType, setAuthType] = useState<"none" | "bearer" | "apiKey">( + "none", + ); + const [credentials, setCredentials] = useState(""); + const [saving, setSaving] = useState(false); + + // Reset form state when dialog opens + useEffect(() => { + if (open) { + setName(server?.name ?? ""); + setUrl(server?.url ?? ""); + setAuthType(server?.authType ?? "none"); + setCredentials(""); // CRITICAL: never pre-fill with masked value + } + }, [open, server]); + + const isEditMode = Boolean(server); + + const isValid = + name.trim().length > 0 && + url.trim().length > 0 && + (authType === "none" || + isEditMode || // in edit mode, empty credentials means "don't change" + credentials.trim().length > 0); + + function handleAuthTypeChange(value: string) { + const newType = value as "none" | "bearer" | "apiKey"; + setAuthType(newType); + if (newType === "none") { + setCredentials(""); // Pitfall 5: clear stale credentials + } + } + + async function handleSubmit() { + const body: Record = { + name: name.trim(), + url: url.trim(), + authType, + }; + + if (authType === "none") { + body.credentials = null; + } else if (credentials !== "") { + // Pitfall 1: only include credentials if user actually typed something + body.credentials = credentials; + } + // In edit mode with empty credentials: omit credentials entirely + + setSaving(true); + try { + await onSave(body); + setOpen(false); + } catch { + // Keep dialog open on error + } finally { + setSaving(false); + } + } + + return ( + + {/* Render trigger as-is wrapped in a span to avoid nesting issues */} + setOpen(true)} + onKeyDown={(e) => e.key === "Enter" && setOpen(true)} + style={{ display: "contents" }} + > + {trigger} + + + + + {isEditMode ? "Edit MCP Server" : "Add MCP Server"} + + + +
+ {/* Name field */} +
+ + setName(e.target.value)} + placeholder="My Server" + /> +
+ + {/* URL field */} +
+ + setUrl(e.target.value)} + placeholder="https://mcp.example.com/sse" + /> +
+ + {/* Auth Type field */} +
+ + +
+ + {/* Credentials field (conditional) */} + {authType !== "none" && ( +
+ + setCredentials(e.target.value)} + placeholder={server?.credentials ?? ""} + /> +
+ )} +
+ + + + +
+
+ ); +} From 72acfd53d0eb2b93fd024237066325543e989ab7 Mon Sep 17 00:00:00 2001 From: fjunqueira Date: Tue, 24 Feb 2026 22:46:22 -0300 Subject: [PATCH 08/20] =?UTF-8?q?feat(03-02):=20wire=20full=20CRUD=20UI=20?= =?UTF-8?q?=E2=80=94=20DeleteServerAlert,=20McpServerActions,=20updated=20?= =?UTF-8?q?list=20and=20settings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DeleteServerAlert: AlertDialog with destructive confirm for server deletion - McpServerActions: Popover with Edit (McpServerFormDialog) and Delete (DeleteServerAlert) - McpServerList: updated onAdd prop type to async fn, renders McpServerFormDialog in header - settings/index.tsx: destructures addServer/updateServer/deleteServer, passes renderActions and onAdd to McpServerList; renderActions returns McpServerActions only for !isDefault servers --- .../mcp-servers/delete-server-alert.tsx | 50 ++++++++++++ .../mcp-servers/mcp-server-actions.tsx | 77 +++++++++++++++++++ .../mcp-servers/mcp-server-list.tsx | 24 ++++-- apps/web/src/features/settings/index.tsx | 20 ++++- 4 files changed, 162 insertions(+), 9 deletions(-) create mode 100644 apps/web/src/features/settings/components/mcp-servers/delete-server-alert.tsx create mode 100644 apps/web/src/features/settings/components/mcp-servers/mcp-server-actions.tsx diff --git a/apps/web/src/features/settings/components/mcp-servers/delete-server-alert.tsx b/apps/web/src/features/settings/components/mcp-servers/delete-server-alert.tsx new file mode 100644 index 00000000..c36d9dc8 --- /dev/null +++ b/apps/web/src/features/settings/components/mcp-servers/delete-server-alert.tsx @@ -0,0 +1,50 @@ +"use client"; + +import React from "react"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/components/ui/alert-dialog"; + +interface DeleteServerAlertProps { + serverName: string; + onDelete: () => void; + trigger: React.ReactNode; +} + +export function DeleteServerAlert({ + serverName, + onDelete, + trigger, +}: DeleteServerAlertProps): React.ReactNode { + return ( + + {trigger} + + + Delete MCP Server + + Are you sure you want to delete "{serverName}"? This + cannot be undone. + + + + Cancel + + Delete + + + + + ); +} diff --git a/apps/web/src/features/settings/components/mcp-servers/mcp-server-actions.tsx b/apps/web/src/features/settings/components/mcp-servers/mcp-server-actions.tsx new file mode 100644 index 00000000..d0959ab0 --- /dev/null +++ b/apps/web/src/features/settings/components/mcp-servers/mcp-server-actions.tsx @@ -0,0 +1,77 @@ +"use client"; + +import React from "react"; +import { MoreVertical, Pencil, Trash2 } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import type { McpServer } from "../../hooks/use-mcp-servers"; +import { McpServerFormDialog } from "./mcp-server-form-dialog"; +import { DeleteServerAlert } from "./delete-server-alert"; + +interface McpServerActionsProps { + server: McpServer; + onEdit: (id: string, body: Record) => Promise; + onDelete: (id: string) => void; +} + +export function McpServerActions({ + server, + onEdit, + onDelete, +}: McpServerActionsProps): React.ReactNode { + return ( + + e.stopPropagation()} + > + + + +
+ onEdit(server.id, body)} + trigger={ + + } + /> + onDelete(server.id)} + trigger={ + + } + /> +
+
+
+ ); +} diff --git a/apps/web/src/features/settings/components/mcp-servers/mcp-server-list.tsx b/apps/web/src/features/settings/components/mcp-servers/mcp-server-list.tsx index c670d15a..37e8f098 100644 --- a/apps/web/src/features/settings/components/mcp-servers/mcp-server-list.tsx +++ b/apps/web/src/features/settings/components/mcp-servers/mcp-server-list.tsx @@ -1,8 +1,11 @@ "use client"; import React from "react"; +import { Plus } from "lucide-react"; +import { Button } from "@/components/ui/button"; import { Skeleton } from "@/components/ui/skeleton"; import { McpServerRow } from "./mcp-server-row"; +import { McpServerFormDialog } from "./mcp-server-form-dialog"; import type { McpServer } from "../../hooks/use-mcp-servers"; interface McpServerListProps { @@ -10,7 +13,7 @@ interface McpServerListProps { loading: boolean; onToggle: (id: string, enabled: boolean) => void; renderActions?: (server: McpServer) => React.ReactNode; - onAdd?: () => void; + onAdd?: (body: Record) => Promise; } export function McpServerList({ @@ -28,13 +31,18 @@ export function McpServerList({ Manage MCP server connections for your agents.

{onAdd && ( - + + + Add Server + + } + /> )} diff --git a/apps/web/src/features/settings/index.tsx b/apps/web/src/features/settings/index.tsx index ed4dea7f..374b94c0 100644 --- a/apps/web/src/features/settings/index.tsx +++ b/apps/web/src/features/settings/index.tsx @@ -8,12 +8,20 @@ import { Label } from "@/components/ui/label"; import { useLocalStorage } from "@/hooks/use-local-storage"; import { useMcpServers } from "./hooks/use-mcp-servers"; import { McpServerList } from "./components/mcp-servers/mcp-server-list"; +import { McpServerActions } from "./components/mcp-servers/mcp-server-actions"; /** * The Settings interface component containing API Keys configuration. */ export default function SettingsInterface(): React.ReactNode { - const { servers, loading: mcpLoading, toggleServer } = useMcpServers(); + const { + servers, + loading: mcpLoading, + toggleServer, + addServer, + updateServer, + deleteServer, + } = useMcpServers(); // Use localStorage hooks for each API key const [openaiApiKey, setOpenaiApiKey] = useLocalStorage( @@ -100,6 +108,16 @@ export default function SettingsInterface(): React.ReactNode { servers={servers} loading={mcpLoading} onToggle={toggleServer} + onAdd={addServer} + renderActions={(server) => + !server.isDefault ? ( + + ) : null + } /> From cfed4194f62243c7e2912a41c4cca2e0c5826034 Mon Sep 17 00:00:00 2001 From: fjunqueira Date: Tue, 24 Feb 2026 23:00:09 -0300 Subject: [PATCH 09/20] fix: remove nested html/body from (app) layout to fix hydration error The (app)/layout.tsx duplicated html/head/body tags already present in the root layout.tsx, causing hydration errors and breaking sidebar rendering (including tenant dropdown). Co-Authored-By: Claude Opus 4.6 --- apps/web/src/app/(app)/layout.tsx | 66 ++++++++++--------------------- 1 file changed, 21 insertions(+), 45 deletions(-) diff --git a/apps/web/src/app/(app)/layout.tsx b/apps/web/src/app/(app)/layout.tsx index d7d1bb30..f796304b 100644 --- a/apps/web/src/app/(app)/layout.tsx +++ b/apps/web/src/app/(app)/layout.tsx @@ -1,23 +1,9 @@ -import type { Metadata } from "next"; -import "../globals.css"; -import { Inter } from "next/font/google"; import React from "react"; import { NuqsAdapter } from "nuqs/adapters/next/app"; import { SidebarLayout } from "@/components/sidebar"; import { AuthProvider } from "@/providers/Auth"; import { DOCS_LINK } from "@/constants"; -const inter = Inter({ - subsets: ["latin"], - preload: true, - display: "swap", -}); - -export const metadata: Metadata = { - title: "Open Agent Platform", - description: "Open Agent Platform by LangChain", -}; - export default function RootLayout({ children, }: Readonly<{ @@ -25,36 +11,26 @@ export default function RootLayout({ }>) { const isDemoApp = process.env.NEXT_PUBLIC_DEMO_APP === "true"; return ( - - - {process.env.NODE_ENV !== "production" && ( -