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 (
+
+ );
+}
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" && (
-
- )}
-
-
- {isDemoApp && (
-
- You're currently using the demo application. To use your own agents,
- and run in production, check out the{" "}
-
- documentation
-
-
- )}
-
-
- {children}
-
-
-
-
+ <>
+ {isDemoApp && (
+
+ You're currently using the demo application. To use your own agents,
+ and run in production, check out the{" "}
+
+ documentation
+
+
+ )}
+
+
+ {children}
+
+
+ >
);
}
From c02b0550a5e96a1e36862af29633644b7edf1f48 Mon Sep 17 00:00:00 2001
From: fjunqueira
Date: Wed, 25 Feb 2026 12:15:51 -0300
Subject: [PATCH 10/20] feat(04-01): add MCP tool proxy and snapshot API routes
- GET /api/mcp-servers/[id]/tools: decrypts credentials server-side, connects via StreamableHTTPClientTransport, returns listTools() result
- GET /api/mcp-servers/snapshot: accepts ids[] query param, returns decrypted server objects for embedding in agent configurable
- Both routes handle default servers (env-based) and MongoDB servers
- Node.js runtime (not Edge) for crypto and Mongoose compatibility
---
.../app/api/mcp-servers/[id]/tools/route.ts | 81 ++++++++++++++++
.../src/app/api/mcp-servers/snapshot/route.ts | 96 +++++++++++++++++++
2 files changed, 177 insertions(+)
create mode 100644 apps/web/src/app/api/mcp-servers/[id]/tools/route.ts
create mode 100644 apps/web/src/app/api/mcp-servers/snapshot/route.ts
diff --git a/apps/web/src/app/api/mcp-servers/[id]/tools/route.ts b/apps/web/src/app/api/mcp-servers/[id]/tools/route.ts
new file mode 100644
index 00000000..fa84e802
--- /dev/null
+++ b/apps/web/src/app/api/mcp-servers/[id]/tools/route.ts
@@ -0,0 +1,81 @@
+import { NextRequest } from "next/server";
+import mongoose from "mongoose";
+import { Client } from "@modelcontextprotocol/sdk/client/index.js";
+import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
+import { connectDB } from "@/lib/mongodb";
+import { decrypt } from "@/lib/encryption";
+import { getDefaultServers } from "@/lib/mcp-defaults";
+import McpServer from "@/models/mcp-server";
+
+export const runtime = "nodejs";
+
+export async function GET(
+ _req: NextRequest,
+ { params }: { params: Promise<{ id: string }> },
+) {
+ const { id } = await params;
+
+ let serverUrl: string;
+ let authType: "none" | "bearer" | "apiKey";
+ let credentials: string | null;
+
+ // Check if this is a default server
+ const defaults = getDefaultServers();
+ const defaultServer = defaults.find((s) => s.id === id);
+
+ if (defaultServer) {
+ serverUrl = defaultServer.url;
+ authType = defaultServer.authType;
+ credentials = defaultServer.credentials;
+ } else {
+ // Validate as MongoDB ObjectId
+ if (!mongoose.Types.ObjectId.isValid(id)) {
+ return Response.json({ error: "Server not found" }, { status: 404 });
+ }
+
+ await connectDB();
+
+ const doc = await McpServer.findById(id).lean();
+
+ if (!doc) {
+ return Response.json({ error: "Server not found" }, { status: 404 });
+ }
+
+ serverUrl = doc.url;
+ authType = doc.authType;
+ credentials = doc.credentials != null ? decrypt(doc.credentials) : null;
+ }
+
+ // Build MCP URL — ensure it ends with /mcp
+ const mcpUrl = serverUrl.endsWith("/mcp")
+ ? serverUrl
+ : `${serverUrl}/mcp`;
+
+ // Build auth headers
+ const headers: Record = {};
+ if (authType === "bearer" && credentials) {
+ headers["Authorization"] = `Bearer ${credentials}`;
+ } else if (authType === "apiKey" && credentials) {
+ headers["x-api-key"] = credentials;
+ }
+
+ const client = new Client({ name: "oap-tool-proxy", version: "1.0.0" });
+
+ try {
+ const transport = new StreamableHTTPClientTransport(new URL(mcpUrl), {
+ requestInit: { headers },
+ });
+
+ await client.connect(transport);
+ const { tools } = await client.listTools();
+
+ return Response.json({ tools });
+ } catch {
+ return Response.json(
+ { error: "Failed to fetch tools" },
+ { status: 502 },
+ );
+ } finally {
+ await client.close().catch(() => {});
+ }
+}
diff --git a/apps/web/src/app/api/mcp-servers/snapshot/route.ts b/apps/web/src/app/api/mcp-servers/snapshot/route.ts
new file mode 100644
index 00000000..645b382d
--- /dev/null
+++ b/apps/web/src/app/api/mcp-servers/snapshot/route.ts
@@ -0,0 +1,96 @@
+import { NextRequest } from "next/server";
+import mongoose from "mongoose";
+import { connectDB } from "@/lib/mongodb";
+import { decrypt } from "@/lib/encryption";
+import { getDefaultServers } from "@/lib/mcp-defaults";
+import McpServer from "@/models/mcp-server";
+
+export const runtime = "nodejs";
+
+interface ServerSnapshot {
+ name: string;
+ url: string;
+ authType: "none" | "bearer" | "apiKey";
+ credentials: string | null;
+}
+
+export async function GET(req: NextRequest) {
+ const { searchParams } = new URL(req.url);
+ const ids = searchParams.getAll("ids[]");
+
+ if (ids.length === 0) {
+ return Response.json({ error: "ids[] required" }, { status: 400 });
+ }
+
+ try {
+ const results: ServerSnapshot[] = [];
+
+ // Separate default server IDs from MongoDB ObjectIds
+ const defaults = getDefaultServers();
+ const defaultIds: string[] = [];
+ const mongoIds: string[] = [];
+
+ for (const id of ids) {
+ if (defaults.some((s) => s.id === id)) {
+ defaultIds.push(id);
+ } else {
+ mongoIds.push(id);
+ }
+ }
+
+ // Resolve default servers
+ for (const id of defaultIds) {
+ const server = defaults.find((s) => s.id === id);
+ if (server) {
+ results.push({
+ name: server.name,
+ url: server.url,
+ authType: server.authType,
+ credentials: server.credentials,
+ });
+ }
+ }
+
+ // Resolve MongoDB servers
+ if (mongoIds.length > 0) {
+ const validIds = mongoIds.filter((id) =>
+ mongoose.Types.ObjectId.isValid(id),
+ );
+
+ if (validIds.length > 0) {
+ await connectDB();
+
+ if (mongoose.connection.readyState !== 1) {
+ return Response.json(
+ { error: "Database not available" },
+ { status: 503 },
+ );
+ }
+
+ const docs = await McpServer.find({
+ _id: { $in: validIds },
+ }).lean();
+
+ for (const doc of docs) {
+ const decrypted =
+ doc.credentials != null ? decrypt(doc.credentials) : null;
+
+ results.push({
+ name: doc.name,
+ url: doc.url,
+ authType: doc.authType,
+ credentials: decrypted,
+ });
+ }
+ }
+ }
+
+ return Response.json({ servers: results });
+ } catch (error) {
+ console.error("[MCP] Failed to build server snapshot:", error);
+ return Response.json(
+ { error: "Failed to build server snapshot" },
+ { status: 500 },
+ );
+ }
+}
From d81d8d6c6d707e9653d768aef2739876929d9bf3 Mon Sep 17 00:00:00 2001
From: fjunqueira
Date: Wed, 25 Feb 2026 12:16:28 -0300
Subject: [PATCH 11/20] feat(04-01): add Checkbox UI component
- Wraps @radix-ui/react-checkbox with project-consistent styling
- Matches switch.tsx pattern: "use client", data-slot, cn() merging
- Checked state: bg-primary + text-primary-foreground + border-primary
- Includes focus-visible ring, disabled state, and Check icon indicator
---
apps/web/src/components/ui/checkbox.tsx | 33 +++++++++++++++++++++++++
1 file changed, 33 insertions(+)
create mode 100644 apps/web/src/components/ui/checkbox.tsx
diff --git a/apps/web/src/components/ui/checkbox.tsx b/apps/web/src/components/ui/checkbox.tsx
new file mode 100644
index 00000000..e16157ab
--- /dev/null
+++ b/apps/web/src/components/ui/checkbox.tsx
@@ -0,0 +1,33 @@
+"use client";
+
+import * as React from "react";
+import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
+import { Check } from "lucide-react";
+
+import { cn } from "@/lib/utils";
+
+function Checkbox({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+
+
+ );
+}
+
+export { Checkbox };
From d2263f4ad2f903eb42d5eb5e275c412d388dd130 Mon Sep 17 00:00:00 2001
From: fjunqueira
Date: Wed, 25 Feb 2026 12:24:21 -0300
Subject: [PATCH 12/20] feat(04-02): create useMcpServerTools hook and
McpServerSelector component
- useMcpServerTools: fetches tool list via /api/mcp-servers/[id]/tools proxy, aborted flag prevents stale state updates, refetch callback for re-running
- McpServerSelector: checkbox list from useMcpServers, tool preview via ServerToolPreview per selected server, unreachable servers auto-removed from selection
- ServerToolPreview: calls useMcpServerTools per server, shows loading text, destructive error text, collapsible tool list with name + description
- Badge "Default" label for default servers, Skeleton loading states, empty-list message
---
.../mcp-server-selector/index.tsx | 207 ++++++++++++++++++
.../use-mcp-server-tools.tsx | 89 ++++++++
2 files changed, 296 insertions(+)
create mode 100644 apps/web/src/features/agents/components/create-edit-agent-dialogs/mcp-server-selector/index.tsx
create mode 100644 apps/web/src/features/agents/components/create-edit-agent-dialogs/mcp-server-selector/use-mcp-server-tools.tsx
diff --git a/apps/web/src/features/agents/components/create-edit-agent-dialogs/mcp-server-selector/index.tsx b/apps/web/src/features/agents/components/create-edit-agent-dialogs/mcp-server-selector/index.tsx
new file mode 100644
index 00000000..a80de918
--- /dev/null
+++ b/apps/web/src/features/agents/components/create-edit-agent-dialogs/mcp-server-selector/index.tsx
@@ -0,0 +1,207 @@
+"use client";
+
+import { Checkbox } from "@/components/ui/checkbox";
+import { Badge } from "@/components/ui/badge";
+import { Skeleton } from "@/components/ui/skeleton";
+import {
+ Collapsible,
+ CollapsibleContent,
+ CollapsibleTrigger,
+} from "@/components/ui/collapsible";
+import {
+ useMcpServers,
+ McpServer,
+} from "@/features/settings/hooks/use-mcp-servers";
+import { useMcpServerTools, Tool } from "./use-mcp-server-tools";
+import { ChevronDown } from "lucide-react";
+import { useEffect } from "react";
+
+// -------------------------------------------------------------------------
+// ServerToolPreview
+// Fetches and renders the tool list for a single selected server.
+// -------------------------------------------------------------------------
+
+interface ServerToolPreviewProps {
+ server: McpServer;
+ onUnreachable: (id: string) => void;
+}
+
+function ServerToolPreview({ server, onUnreachable }: ServerToolPreviewProps) {
+ const { tools, loading, error } = useMcpServerTools(server.id);
+
+ useEffect(() => {
+ if (error) {
+ onUnreachable(server.id);
+ }
+ }, [error, server.id, onUnreachable]);
+
+ if (loading) {
+ return (
+ Loading tools...
+ );
+ }
+
+ if (error) {
+ return (
+ Server unreachable
+ );
+ }
+
+ return (
+
+
+
+
+ {tools.length} {tools.length === 1 ? "tool" : "tools"}
+
+
+
+ {tools.map((tool: Tool) => (
+
+
{tool.name}
+ {tool.description && (
+
{tool.description}
+ )}
+
+ ))}
+ {tools.length === 0 && (
+ No tools available.
+ )}
+
+
+ );
+}
+
+// -------------------------------------------------------------------------
+// ServerRow
+// Renders a single server as a checkbox row, with tool preview when checked.
+// -------------------------------------------------------------------------
+
+interface ServerRowProps {
+ server: McpServer;
+ isSelected: boolean;
+ isDisabled: boolean;
+ onCheckedChange: (checked: boolean) => void;
+ onUnreachable: (id: string) => void;
+}
+
+function ServerRow({
+ server,
+ isSelected,
+ isDisabled,
+ onCheckedChange,
+ onUnreachable,
+}: ServerRowProps) {
+ return (
+
+
+
onCheckedChange(checked === true)}
+ />
+
+
+
{server.url}
+
+
+ {isSelected && (
+
+ )}
+
+ );
+}
+
+// -------------------------------------------------------------------------
+// McpServerSelector
+// Main component: checkbox list of available MCP servers with tool preview.
+// -------------------------------------------------------------------------
+
+export interface McpServerSelectorProps {
+ selectedServerIds: string[];
+ onSelectionChange: (ids: string[]) => void;
+}
+
+export function McpServerSelector({
+ selectedServerIds,
+ onSelectionChange,
+}: McpServerSelectorProps) {
+ const { servers, loading } = useMcpServers();
+
+ const handleCheckedChange = (serverId: string, checked: boolean) => {
+ if (checked) {
+ onSelectionChange([...selectedServerIds, serverId]);
+ } else {
+ onSelectionChange(selectedServerIds.filter((id) => id !== serverId));
+ }
+ };
+
+ const handleUnreachable = (serverId: string) => {
+ // Remove from selection if unreachable
+ if (selectedServerIds.includes(serverId)) {
+ onSelectionChange(selectedServerIds.filter((id) => id !== serverId));
+ }
+ };
+
+ if (loading) {
+ return (
+
+ {Array.from({ length: 3 }).map((_, i) => (
+
+ ))}
+
+ );
+ }
+
+ if (servers.length === 0) {
+ return (
+
+ No MCP servers configured. Add servers in Settings.
+
+ );
+ }
+
+ return (
+
+ {servers.map((server) => {
+ const isSelected = selectedServerIds.includes(server.id);
+ return (
+
+ handleCheckedChange(server.id, checked)
+ }
+ onUnreachable={handleUnreachable}
+ />
+ );
+ })}
+
+ );
+}
diff --git a/apps/web/src/features/agents/components/create-edit-agent-dialogs/mcp-server-selector/use-mcp-server-tools.tsx b/apps/web/src/features/agents/components/create-edit-agent-dialogs/mcp-server-selector/use-mcp-server-tools.tsx
new file mode 100644
index 00000000..b223ad27
--- /dev/null
+++ b/apps/web/src/features/agents/components/create-edit-agent-dialogs/mcp-server-selector/use-mcp-server-tools.tsx
@@ -0,0 +1,89 @@
+"use client";
+
+import { useState, useEffect, useCallback } from "react";
+
+export interface Tool {
+ name: string;
+ description?: string;
+}
+
+interface UseMcpServerToolsReturn {
+ tools: Tool[];
+ loading: boolean;
+ error: string | null;
+ refetch: () => void;
+}
+
+/**
+ * Fetches the tool list for a single MCP server via the server-side proxy API.
+ * The proxy handles credential decryption so credentials never reach the browser.
+ *
+ * @param serverId - The ID of the MCP server, or null to skip fetching.
+ */
+export function useMcpServerTools(
+ serverId: string | null,
+): UseMcpServerToolsReturn {
+ const [tools, setTools] = useState([]);
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(null);
+ const [fetchCounter, setFetchCounter] = useState(0);
+
+ const refetch = useCallback(() => {
+ setFetchCounter((c) => c + 1);
+ }, []);
+
+ useEffect(() => {
+ if (serverId === null) {
+ setTools([]);
+ setLoading(false);
+ setError(null);
+ return;
+ }
+
+ let aborted = false;
+
+ const run = async () => {
+ setLoading(true);
+ setError(null);
+
+ try {
+ const res = await fetch(`/api/mcp-servers/${serverId}/tools`);
+ if (aborted) return;
+
+ if (!res.ok) {
+ setError("Unreachable");
+ setTools([]);
+ return;
+ }
+
+ const data = await res.json();
+ if (aborted) return;
+
+ const parsed: Tool[] = (data.tools ?? []).map(
+ (t: { name: string; description?: string }) => ({
+ name: t.name,
+ description: t.description,
+ }),
+ );
+ setTools(parsed);
+ } catch {
+ if (!aborted) {
+ setError("Unreachable");
+ setTools([]);
+ }
+ } finally {
+ if (!aborted) {
+ setLoading(false);
+ }
+ }
+ };
+
+ run();
+
+ return () => {
+ aborted = true;
+ };
+ }, [serverId, fetchCounter]);
+
+ return { tools, loading, error, refetch };
+}
From 7a832b0821fc558890db83bce4dbca1644bb1af1 Mon Sep 17 00:00:00 2001
From: fjunqueira
Date: Wed, 25 Feb 2026 12:29:36 -0300
Subject: [PATCH 13/20] feat(04-02): integrate McpServerSelector into agent
form with snapshot save
- use-agent-config: detect mcp_servers in graph schema, expose hasMcpServers boolean
- agent-form: add McpServerSelector section (guarded by hasMcpServers), accept selectedMcpServerIds and onMcpSelectionChange props
- edit-agent-dialog: read hasMcpServers from useAgentConfig, call useMcpServers for pre-population, useEffect matches snapshot names to current server IDs, submit handler fetches /api/mcp-servers/snapshot before updateAgent
- create-agent-dialog: new agents default to empty selection (AGNT-02), submit handler fetches snapshot if servers selected
- mcp_servers only written to configurable when graph schema declares the field (avoids Pitfall 3)
---
.../create-edit-agent-dialogs/agent-form.tsx | 21 ++++++
.../create-agent-dialog.tsx | 69 +++++++++++++-----
.../edit-agent-dialog.tsx | 70 +++++++++++++++++--
apps/web/src/hooks/use-agent-config.tsx | 10 +++
4 files changed, 148 insertions(+), 22 deletions(-)
diff --git a/apps/web/src/features/agents/components/create-edit-agent-dialogs/agent-form.tsx b/apps/web/src/features/agents/components/create-edit-agent-dialogs/agent-form.tsx
index a7684912..89040ec8 100644
--- a/apps/web/src/features/agents/components/create-edit-agent-dialogs/agent-form.tsx
+++ b/apps/web/src/features/agents/components/create-edit-agent-dialogs/agent-form.tsx
@@ -23,6 +23,7 @@ import {
import _ from "lodash";
import { useFetchPreselectedTools } from "@/hooks/use-fetch-preselected-tools";
import { Controller, useFormContext } from "react-hook-form";
+import { McpServerSelector } from "./mcp-server-selector";
export function AgentFieldsFormLoading() {
return (
@@ -46,6 +47,9 @@ interface AgentFieldsFormProps {
agentId: string;
ragConfigurations: ConfigurableFieldRAGMetadata[];
agentsConfigurations: ConfigurableFieldAgentsMetadata[];
+ hasMcpServers?: boolean;
+ selectedMcpServerIds?: string[];
+ onMcpSelectionChange?: (ids: string[]) => void;
}
export function AgentFieldsForm({
@@ -54,6 +58,9 @@ export function AgentFieldsForm({
agentId,
ragConfigurations,
agentsConfigurations,
+ hasMcpServers = false,
+ selectedMcpServerIds = [],
+ onMcpSelectionChange,
}: AgentFieldsFormProps) {
const form = useFormContext<{
name: string;
@@ -305,6 +312,20 @@ export function AgentFieldsForm({
>
)}
+ {hasMcpServers && onMcpSelectionChange && (
+ <>
+
+
+ >
+ )}
>
);
diff --git a/apps/web/src/features/agents/components/create-edit-agent-dialogs/create-agent-dialog.tsx b/apps/web/src/features/agents/components/create-edit-agent-dialogs/create-agent-dialog.tsx
index ae945234..c1890ba2 100644
--- a/apps/web/src/features/agents/components/create-edit-agent-dialogs/create-agent-dialog.tsx
+++ b/apps/web/src/features/agents/components/create-edit-agent-dialogs/create-agent-dialog.tsx
@@ -35,6 +35,22 @@ function CreateAgentFormContent(props: {
selectedDeployment: Deployment;
onClose: () => void;
}) {
+ const { createAgent } = useAgents();
+ const { refreshAgents } = useAgentsContext();
+ const {
+ getSchemaAndUpdateConfig,
+ loading,
+ configurations,
+ toolConfigurations,
+ ragConfigurations,
+ agentsConfigurations,
+ hasMcpServers,
+ } = useAgentConfig();
+ const { selectedTenant } = useTenantContext();
+ const [submitting, setSubmitting] = useState(false);
+ // New agents start with no MCP servers selected (AGNT-02)
+ const [selectedMcpServerIds, setSelectedMcpServerIds] = useState([]);
+
const form = useForm<{
name: string;
description: string;
@@ -50,19 +66,6 @@ function CreateAgentFormContent(props: {
},
});
- const { createAgent } = useAgents();
- const { refreshAgents } = useAgentsContext();
- const {
- getSchemaAndUpdateConfig,
- loading,
- configurations,
- toolConfigurations,
- ragConfigurations,
- agentsConfigurations,
- } = useAgentConfig();
- const { selectedTenant } = useTenantContext();
- const [submitting, setSubmitting] = useState(false);
-
const handleSubmit = async (data: {
name: string;
description: string;
@@ -76,6 +79,38 @@ function CreateAgentFormContent(props: {
return;
}
+ let mcpServersPayload: unknown[] | undefined;
+
+ if (hasMcpServers) {
+ if (selectedMcpServerIds.length > 0) {
+ // Fetch decrypted server snapshots for selected servers
+ const qs = selectedMcpServerIds.map((id) => `ids[]=${encodeURIComponent(id)}`).join("&");
+ const snapshotRes = await fetch(`/api/mcp-servers/snapshot?${qs}`);
+ if (!snapshotRes.ok) {
+ toast.error("Failed to fetch MCP server configuration", {
+ description: "Please try again",
+ richColors: true,
+ });
+ return;
+ }
+ const snapshotData = await snapshotRes.json();
+ mcpServersPayload = snapshotData.servers ?? [];
+ } else {
+ // Explicit empty array — new agent has no MCP servers
+ mcpServersPayload = [];
+ }
+ }
+
+ const configPayload: Record = {
+ ...config,
+ tenant: selectedTenant?.tenantName,
+ };
+
+ // Only include mcp_servers if the graph schema declares it
+ if (hasMcpServers) {
+ configPayload.mcp_servers = mcpServersPayload;
+ }
+
setSubmitting(true);
const newAgent = await createAgent(
props.selectedDeployment.id,
@@ -83,10 +118,7 @@ function CreateAgentFormContent(props: {
{
name,
description,
- config: {
- ...config,
- tenant: selectedTenant?.tenantName,
- },
+ config: configPayload,
},
);
setSubmitting(false);
@@ -120,6 +152,9 @@ function CreateAgentFormContent(props: {
toolConfigurations={toolConfigurations}
ragConfigurations={ragConfigurations}
agentsConfigurations={agentsConfigurations}
+ hasMcpServers={hasMcpServers}
+ selectedMcpServerIds={selectedMcpServerIds}
+ onMcpSelectionChange={setSelectedMcpServerIds}
/>
)}
diff --git a/apps/web/src/features/agents/components/create-edit-agent-dialogs/edit-agent-dialog.tsx b/apps/web/src/features/agents/components/create-edit-agent-dialogs/edit-agent-dialog.tsx
index f27c5564..8721ade6 100644
--- a/apps/web/src/features/agents/components/create-edit-agent-dialogs/edit-agent-dialog.tsx
+++ b/apps/web/src/features/agents/components/create-edit-agent-dialogs/edit-agent-dialog.tsx
@@ -11,7 +11,7 @@ import {
import { useAgents } from "@/hooks/use-agents";
import { useAgentConfig } from "@/hooks/use-agent-config";
import { Bot, LoaderCircle, Trash, X } from "lucide-react";
-import { useLayoutEffect, useRef, useState } from "react";
+import { useEffect, useLayoutEffect, useRef, useState } from "react";
import { toast } from "sonner";
import { useAgentsContext } from "@/providers/Agents";
import { useTenantContext } from "@/providers/Tenant";
@@ -20,6 +20,7 @@ import { Agent } from "@/types/agent";
import { FormProvider, useForm } from "react-hook-form";
import { hasStaleSupervisors } from "@/lib/agent-utils";
import { StaleSupervisorsWarningDialog } from "./stale-supervisors-warning-dialog";
+import { useMcpServers } from "@/features/settings/hooks/use-mcp-servers";
interface EditAgentDialogProps {
agent: Agent;
@@ -46,9 +47,14 @@ function EditAgentDialogContent({
toolConfigurations,
ragConfigurations,
agentsConfigurations,
+ hasMcpServers,
} = useAgentConfig();
const { selectedTenant } = useTenantContext();
const [deleteSubmitting, setDeleteSubmitting] = useState(false);
+ const [selectedMcpServerIds, setSelectedMcpServerIds] = useState([]);
+
+ // For pre-populating selected servers on edit: match existing snapshot names to current server IDs
+ const { servers: availableServers } = useMcpServers();
const form = useForm<{
name: string;
@@ -63,6 +69,29 @@ function EditAgentDialogContent({
},
});
+ // Pre-populate selectedMcpServerIds from the existing snapshot once both the server list
+ // and schema detection are ready. Match by name — the most stable identifier across
+ // a stored snapshot and the current live server list.
+ const initializedRef = useRef(false);
+ useEffect(() => {
+ if (initializedRef.current) return;
+ if (!hasMcpServers) return;
+ if (availableServers.length === 0) return;
+
+ initializedRef.current = true;
+
+ const rawSnapshot = (agent.config?.configurable?.mcp_servers ?? []) as unknown;
+ const existingSnapshot: { name?: string }[] = Array.isArray(rawSnapshot) ? rawSnapshot : [];
+ if (existingSnapshot.length > 0) {
+ const ids = existingSnapshot
+ .map((snap) => availableServers.find((s) => s.name === snap.name)?.id)
+ .filter((id): id is string => id !== undefined);
+ if (ids.length > 0) {
+ setSelectedMcpServerIds(ids);
+ }
+ }
+ }, [hasMcpServers, availableServers, agent.config?.configurable?.mcp_servers]);
+
const handleSubmit = async (data: {
name: string;
description: string;
@@ -73,15 +102,43 @@ function EditAgentDialogContent({
return;
}
+ let mcpServersPayload: unknown[] | undefined;
+
+ if (hasMcpServers) {
+ if (selectedMcpServerIds.length > 0) {
+ // Fetch decrypted server snapshots for selected servers
+ const qs = selectedMcpServerIds.map((id) => `ids[]=${encodeURIComponent(id)}`).join("&");
+ const snapshotRes = await fetch(`/api/mcp-servers/snapshot?${qs}`);
+ if (!snapshotRes.ok) {
+ toast.error("Failed to fetch MCP server configuration", {
+ description: "Please try again",
+ });
+ return;
+ }
+ const snapshotData = await snapshotRes.json();
+ mcpServersPayload = snapshotData.servers ?? [];
+ } else {
+ // Explicit empty array — agent has no MCP servers assigned
+ mcpServersPayload = [];
+ }
+ }
+
+ const configPayload: Record = {
+ ...data.config,
+ tenant: selectedTenant?.tenantName,
+ };
+
+ // Only include mcp_servers if the graph schema declares it
+ if (hasMcpServers) {
+ configPayload.mcp_servers = mcpServersPayload;
+ }
+
const updatedAgent = await updateAgent(
agent.assistant_id,
agent.deploymentId,
{
...data,
- config: {
- ...data.config,
- tenant: selectedTenant?.tenantName,
- },
+ config: configPayload,
},
);
@@ -148,6 +205,9 @@ function EditAgentDialogContent({
agentId={agent.assistant_id}
ragConfigurations={ragConfigurations}
agentsConfigurations={agentsConfigurations}
+ hasMcpServers={hasMcpServers}
+ selectedMcpServerIds={selectedMcpServerIds}
+ onMcpSelectionChange={setSelectedMcpServerIds}
/>
)}
diff --git a/apps/web/src/hooks/use-agent-config.tsx b/apps/web/src/hooks/use-agent-config.tsx
index 9bcd0637..9649890e 100644
--- a/apps/web/src/hooks/use-agent-config.tsx
+++ b/apps/web/src/hooks/use-agent-config.tsx
@@ -39,12 +39,14 @@ export function useAgentConfig() {
const [supportedConfigs, setSupportedConfigs] = useState([]);
const [loading, setLoading] = useState(false);
+ const [hasMcpServers, setHasMcpServers] = useState(false);
const clearState = useCallback(() => {
setConfigurations([]);
setToolConfigurations([]);
setRagConfigurations([]);
setAgentsConfigurations([]);
+ setHasMcpServers(false);
setLoading(false);
}, []);
@@ -71,6 +73,13 @@ export function useAgentConfig() {
(agent.metadata?.description as string | undefined) ?? "",
config: {},
};
+
+ // Detect whether the graph schema declares mcp_servers (type: "hidden").
+ // This field is filtered out of configFields but we still need to know it exists
+ // so we can show the MCP Servers section in the agent form.
+ const schemaHasMcpServers = !!schema?.properties?.["mcp_servers"];
+ setHasMcpServers(schemaHasMcpServers);
+
const { configFields, toolConfig, ragConfig, agentsConfig } =
extractConfigurationsFromAgent({
agent,
@@ -142,6 +151,7 @@ export function useAgentConfig() {
ragConfigurations,
agentsConfigurations,
supportedConfigs,
+ hasMcpServers,
loading,
};
From efb4dadd0d5d94357d0963ddce32830bb74122d0 Mon Sep 17 00:00:00 2001
From: fjunqueira
Date: Wed, 25 Feb 2026 15:29:15 -0300
Subject: [PATCH 14/20] :sparkles: add multi mcp support
---
apps/docs/quickstart.mdx | 4 +-
apps/docs/setup/authentication.mdx | 2 +-
apps/web/.env.bak | 4 +-
apps/web/.env.example | 4 +-
apps/web/package.json | 2 +-
.../app/api/mcp-servers/[id]/tools/route.ts | 42 ++-
apps/web/src/components/ui/checkbox.tsx | 2 +-
.../create-edit-agent-dialogs/agent-form.tsx | 146 +++++------
.../create-agent-dialog.tsx | 30 ++-
.../edit-agent-dialog.tsx | 44 +++-
.../mcp-server-selector/index.tsx | 207 ---------------
.../mcp-server-tool-groups.tsx | 245 ++++++++++++++++++
.../use-mcp-server-tools.tsx | 8 +-
13 files changed, 420 insertions(+), 320 deletions(-)
delete mode 100644 apps/web/src/features/agents/components/create-edit-agent-dialogs/mcp-server-selector/index.tsx
create mode 100644 apps/web/src/features/agents/components/create-edit-agent-dialogs/mcp-server-selector/mcp-server-tool-groups.tsx
diff --git a/apps/docs/quickstart.mdx b/apps/docs/quickstart.mdx
index 18756f10..183c6977 100644
--- a/apps/docs/quickstart.mdx
+++ b/apps/docs/quickstart.mdx
@@ -24,7 +24,7 @@ description: 'Follow these steps to get your Open Agent Platform up and running
Set the following environment variables:
```bash
- NEXT_PUBLIC_BASE_API_URL="http://localhost:3000/api"
+ NEXT_PUBLIC_BASE_API_URL="http://localhost:3001/api"
LANGSMITH_API_KEY="lsv2_..."
# Or whichever LLM's API key you're using
OPENAI_API_KEY="..."
@@ -151,4 +151,4 @@ yarn install
yarn dev
```
-Your Open Agent Platform should now be running at http://localhost:3000!
+Your Open Agent Platform should now be running at http://localhost:3001!
diff --git a/apps/docs/setup/authentication.mdx b/apps/docs/setup/authentication.mdx
index fc812adb..03124206 100644
--- a/apps/docs/setup/authentication.mdx
+++ b/apps/docs/setup/authentication.mdx
@@ -31,7 +31,7 @@ If you do *not* want to use custom authentication in your LangGraph server, and
Lastly, ensure you have the `NEXT_PUBLIC_BASE_API_URL` environment variable set to the base API URL of your **web** server. For local development, this should be set to:
```bash
-NEXT_PUBLIC_BASE_API_URL="http://localhost:3000/api"
+NEXT_PUBLIC_BASE_API_URL="http://localhost:3001/api"
```
This will cause all requests made to your web client to first pass through a proxy route, which injects the LangSmith API key into the request from the server, as to not expose the API key to the client. The request is then forwarded on to your LangGraph server.
diff --git a/apps/web/.env.bak b/apps/web/.env.bak
index b07c28b1..8a7bd43f 100644
--- a/apps/web/.env.bak
+++ b/apps/web/.env.bak
@@ -1,6 +1,6 @@
# The base API URL for the platform.
-# Defaults to `http://localhost:3000/api` for development
-NEXT_PUBLIC_BASE_API_URL="http://localhost:3000/api"
+# Defaults to `http://localhost:3001/api` for development
+NEXT_PUBLIC_BASE_API_URL="http://localhost:3001/api"
# LangSmith API key required for some admin tasks.
LANGSMITH_API_KEY="lsv2_..."
diff --git a/apps/web/.env.example b/apps/web/.env.example
index b07c28b1..8a7bd43f 100644
--- a/apps/web/.env.example
+++ b/apps/web/.env.example
@@ -1,6 +1,6 @@
# The base API URL for the platform.
-# Defaults to `http://localhost:3000/api` for development
-NEXT_PUBLIC_BASE_API_URL="http://localhost:3000/api"
+# Defaults to `http://localhost:3001/api` for development
+NEXT_PUBLIC_BASE_API_URL="http://localhost:3001/api"
# LangSmith API key required for some admin tasks.
LANGSMITH_API_KEY="lsv2_..."
diff --git a/apps/web/package.json b/apps/web/package.json
index e9e9231a..29b83f6d 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -4,7 +4,7 @@
"version": "0.0.0",
"type": "module",
"scripts": {
- "dev": "next dev",
+ "dev": "next dev -p 3001",
"build": "turbo build:internal --filter=@open-agent-platform/web",
"build:internal": "next build",
"start": "next start",
diff --git a/apps/web/src/app/api/mcp-servers/[id]/tools/route.ts b/apps/web/src/app/api/mcp-servers/[id]/tools/route.ts
index fa84e802..4891462a 100644
--- a/apps/web/src/app/api/mcp-servers/[id]/tools/route.ts
+++ b/apps/web/src/app/api/mcp-servers/[id]/tools/route.ts
@@ -8,6 +8,7 @@ import { getDefaultServers } from "@/lib/mcp-defaults";
import McpServer from "@/models/mcp-server";
export const runtime = "nodejs";
+export const dynamic = "force-dynamic";
export async function GET(
_req: NextRequest,
@@ -59,22 +60,45 @@ export async function GET(
headers["x-api-key"] = credentials;
}
+ // Forward tenant header when present
+ const tenant = _req.nextUrl.searchParams.get("tenant");
+ if (tenant) {
+ headers["x-tenant"] = tenant;
+ }
+
const client = new Client({ name: "oap-tool-proxy", version: "1.0.0" });
+ const TIMEOUT_MS = 30_000;
+ const timeoutError = Symbol("timeout");
+
try {
const transport = new StreamableHTTPClientTransport(new URL(mcpUrl), {
- requestInit: { headers },
+ requestInit: { headers, cache: "no-store" as RequestCache },
});
- await client.connect(transport);
- const { tools } = await client.listTools();
+ const result = await Promise.race([
+ (async () => {
+ await client.connect(transport);
+ return client.listTools();
+ })(),
+ new Promise((_, reject) =>
+ setTimeout(() => reject(timeoutError), TIMEOUT_MS),
+ ),
+ ]);
- return Response.json({ tools });
- } catch {
- return Response.json(
- { error: "Failed to fetch tools" },
- { status: 502 },
- );
+ return Response.json({ tools: result.tools });
+ } catch (err) {
+ if (err === timeoutError) {
+ console.error(`[mcp-tools] Timed out fetching tools from ${mcpUrl}`);
+ return Response.json(
+ { error: "MCP server timed out after 30 seconds" },
+ { status: 504 },
+ );
+ }
+ console.error(`[mcp-tools] Failed to fetch tools from ${mcpUrl}:`, err);
+ const message =
+ err instanceof Error ? err.message : "Failed to fetch tools";
+ return Response.json({ error: message }, { status: 502 });
} finally {
await client.close().catch(() => {});
}
diff --git a/apps/web/src/components/ui/checkbox.tsx b/apps/web/src/components/ui/checkbox.tsx
index e16157ab..0f7d09d2 100644
--- a/apps/web/src/components/ui/checkbox.tsx
+++ b/apps/web/src/components/ui/checkbox.tsx
@@ -14,7 +14,7 @@ function Checkbox({
void;
+ selectedToolsByServer?: Record;
+ onMcpToolSelectionChange?: (selection: Record) => void;
+ tenant?: string;
}
export function AgentFieldsForm({
@@ -59,8 +60,9 @@ export function AgentFieldsForm({
ragConfigurations,
agentsConfigurations,
hasMcpServers = false,
- selectedMcpServerIds = [],
- onMcpSelectionChange,
+ selectedToolsByServer = {},
+ onMcpToolSelectionChange,
+ tenant,
}: AgentFieldsFormProps) {
const form = useFormContext<{
name: string;
@@ -191,7 +193,7 @@ export function AgentFieldsForm({
>
)}
- {toolConfigurations.length > 0 && (
+ {(toolConfigurations.length > 0 || hasMcpServers) && (
<>
@@ -205,62 +207,74 @@ export function AgentFieldsForm({
/>
- {toolConfigurations[0]?.label
- ? displayTools.map((c) => (
-
(
- 0 && !hasMcpServers && (
+ <>
+ {toolConfigurations[0]?.label
+ ? displayTools.map((c) => (
+ (
+
+ )}
/>
- )}
- />
- ))
- : null}
- {displayTools.length === 0 && toolSearchTerm && (
-
- No tools found matching "{toolSearchTerm}".
-
+ ))
+ : null}
+ {displayTools.length === 0 && toolSearchTerm && (
+
+ No tools found matching "{toolSearchTerm}".
+
+ )}
+ {tools.length === 0 && !toolSearchTerm && (
+
+ No tools available for this agent.
+
+ )}
+ {cursor && !toolSearchTerm && (
+
+
+
+ )}
+ >
)}
- {tools.length === 0 && !toolSearchTerm && (
-
- No tools available for this agent.
-
- )}
- {cursor && !toolSearchTerm && (
-
-
-
+ {hasMcpServers && onMcpToolSelectionChange && (
+
)}
@@ -312,20 +326,6 @@ export function AgentFieldsForm({
>
)}
- {hasMcpServers && onMcpSelectionChange && (
- <>
-
-
- >
- )}
>
);
diff --git a/apps/web/src/features/agents/components/create-edit-agent-dialogs/create-agent-dialog.tsx b/apps/web/src/features/agents/components/create-edit-agent-dialogs/create-agent-dialog.tsx
index c1890ba2..9cff2418 100644
--- a/apps/web/src/features/agents/components/create-edit-agent-dialogs/create-agent-dialog.tsx
+++ b/apps/web/src/features/agents/components/create-edit-agent-dialogs/create-agent-dialog.tsx
@@ -21,6 +21,7 @@ import { getDeployments } from "@/lib/environment/deployments";
import { GraphSelect } from "./graph-select";
import { useAgentConfig } from "@/hooks/use-agent-config";
import { FormProvider, useForm } from "react-hook-form";
+import { useMcpServers } from "@/features/settings/hooks/use-mcp-servers";
interface CreateAgentDialogProps {
agentId?: string;
@@ -47,9 +48,10 @@ function CreateAgentFormContent(props: {
hasMcpServers,
} = useAgentConfig();
const { selectedTenant } = useTenantContext();
+ const { servers: availableServers } = useMcpServers();
const [submitting, setSubmitting] = useState(false);
- // New agents start with no MCP servers selected (AGNT-02)
- const [selectedMcpServerIds, setSelectedMcpServerIds] = useState([]);
+ // New agents start with no MCP tools selected
+ const [selectedToolsByServer, setSelectedToolsByServer] = useState>({});
const form = useForm<{
name: string;
@@ -82,9 +84,14 @@ function CreateAgentFormContent(props: {
let mcpServersPayload: unknown[] | undefined;
if (hasMcpServers) {
- if (selectedMcpServerIds.length > 0) {
+ // Only include servers that have at least 1 tool selected
+ const serverIdsWithTools = Object.entries(selectedToolsByServer)
+ .filter(([, tools]) => tools.length > 0)
+ .map(([id]) => id);
+
+ if (serverIdsWithTools.length > 0) {
// Fetch decrypted server snapshots for selected servers
- const qs = selectedMcpServerIds.map((id) => `ids[]=${encodeURIComponent(id)}`).join("&");
+ const qs = serverIdsWithTools.map((id) => `ids[]=${encodeURIComponent(id)}`).join("&");
const snapshotRes = await fetch(`/api/mcp-servers/snapshot?${qs}`);
if (!snapshotRes.ok) {
toast.error("Failed to fetch MCP server configuration", {
@@ -94,7 +101,15 @@ function CreateAgentFormContent(props: {
return;
}
const snapshotData = await snapshotRes.json();
- mcpServersPayload = snapshotData.servers ?? [];
+ // Augment each snapshot with its selected tools array
+ const servers = (snapshotData.servers ?? []) as Record[];
+ mcpServersPayload = servers.map((snap) => {
+ const server = availableServers.find((s) => s.name === (snap as { name?: string }).name);
+ return {
+ ...snap,
+ tools: server ? (selectedToolsByServer[server.id] ?? []) : [],
+ };
+ });
} else {
// Explicit empty array — new agent has no MCP servers
mcpServersPayload = [];
@@ -153,8 +168,9 @@ function CreateAgentFormContent(props: {
ragConfigurations={ragConfigurations}
agentsConfigurations={agentsConfigurations}
hasMcpServers={hasMcpServers}
- selectedMcpServerIds={selectedMcpServerIds}
- onMcpSelectionChange={setSelectedMcpServerIds}
+ selectedToolsByServer={selectedToolsByServer}
+ onMcpToolSelectionChange={setSelectedToolsByServer}
+ tenant={selectedTenant?.tenantName}
/>
)}
diff --git a/apps/web/src/features/agents/components/create-edit-agent-dialogs/edit-agent-dialog.tsx b/apps/web/src/features/agents/components/create-edit-agent-dialogs/edit-agent-dialog.tsx
index 8721ade6..e153718d 100644
--- a/apps/web/src/features/agents/components/create-edit-agent-dialogs/edit-agent-dialog.tsx
+++ b/apps/web/src/features/agents/components/create-edit-agent-dialogs/edit-agent-dialog.tsx
@@ -51,7 +51,7 @@ function EditAgentDialogContent({
} = useAgentConfig();
const { selectedTenant } = useTenantContext();
const [deleteSubmitting, setDeleteSubmitting] = useState(false);
- const [selectedMcpServerIds, setSelectedMcpServerIds] = useState([]);
+ const [selectedToolsByServer, setSelectedToolsByServer] = useState>({});
// For pre-populating selected servers on edit: match existing snapshot names to current server IDs
const { servers: availableServers } = useMcpServers();
@@ -69,7 +69,7 @@ function EditAgentDialogContent({
},
});
- // Pre-populate selectedMcpServerIds from the existing snapshot once both the server list
+ // Pre-populate selectedToolsByServer from the existing snapshot once both the server list
// and schema detection are ready. Match by name — the most stable identifier across
// a stored snapshot and the current live server list.
const initializedRef = useRef(false);
@@ -81,13 +81,17 @@ function EditAgentDialogContent({
initializedRef.current = true;
const rawSnapshot = (agent.config?.configurable?.mcp_servers ?? []) as unknown;
- const existingSnapshot: { name?: string }[] = Array.isArray(rawSnapshot) ? rawSnapshot : [];
+ const existingSnapshot: { name?: string; tools?: string[] }[] = Array.isArray(rawSnapshot) ? rawSnapshot : [];
if (existingSnapshot.length > 0) {
- const ids = existingSnapshot
- .map((snap) => availableServers.find((s) => s.name === snap.name)?.id)
- .filter((id): id is string => id !== undefined);
- if (ids.length > 0) {
- setSelectedMcpServerIds(ids);
+ const toolsByServer: Record = {};
+ for (const snap of existingSnapshot) {
+ const server = availableServers.find((s) => s.name === snap.name);
+ if (server && Array.isArray(snap.tools) && snap.tools.length > 0) {
+ toolsByServer[server.id] = snap.tools;
+ }
+ }
+ if (Object.keys(toolsByServer).length > 0) {
+ setSelectedToolsByServer(toolsByServer);
}
}
}, [hasMcpServers, availableServers, agent.config?.configurable?.mcp_servers]);
@@ -105,9 +109,14 @@ function EditAgentDialogContent({
let mcpServersPayload: unknown[] | undefined;
if (hasMcpServers) {
- if (selectedMcpServerIds.length > 0) {
+ // Only include servers that have at least 1 tool selected
+ const serverIdsWithTools = Object.entries(selectedToolsByServer)
+ .filter(([, tools]) => tools.length > 0)
+ .map(([id]) => id);
+
+ if (serverIdsWithTools.length > 0) {
// Fetch decrypted server snapshots for selected servers
- const qs = selectedMcpServerIds.map((id) => `ids[]=${encodeURIComponent(id)}`).join("&");
+ const qs = serverIdsWithTools.map((id) => `ids[]=${encodeURIComponent(id)}`).join("&");
const snapshotRes = await fetch(`/api/mcp-servers/snapshot?${qs}`);
if (!snapshotRes.ok) {
toast.error("Failed to fetch MCP server configuration", {
@@ -116,7 +125,15 @@ function EditAgentDialogContent({
return;
}
const snapshotData = await snapshotRes.json();
- mcpServersPayload = snapshotData.servers ?? [];
+ // Augment each snapshot with its selected tools array
+ const servers = (snapshotData.servers ?? []) as Record[];
+ mcpServersPayload = servers.map((snap) => {
+ const server = availableServers.find((s) => s.name === (snap as { name?: string }).name);
+ return {
+ ...snap,
+ tools: server ? (selectedToolsByServer[server.id] ?? []) : [],
+ };
+ });
} else {
// Explicit empty array — agent has no MCP servers assigned
mcpServersPayload = [];
@@ -206,8 +223,9 @@ function EditAgentDialogContent({
ragConfigurations={ragConfigurations}
agentsConfigurations={agentsConfigurations}
hasMcpServers={hasMcpServers}
- selectedMcpServerIds={selectedMcpServerIds}
- onMcpSelectionChange={setSelectedMcpServerIds}
+ selectedToolsByServer={selectedToolsByServer}
+ onMcpToolSelectionChange={setSelectedToolsByServer}
+ tenant={selectedTenant?.tenantName}
/>
)}
diff --git a/apps/web/src/features/agents/components/create-edit-agent-dialogs/mcp-server-selector/index.tsx b/apps/web/src/features/agents/components/create-edit-agent-dialogs/mcp-server-selector/index.tsx
deleted file mode 100644
index a80de918..00000000
--- a/apps/web/src/features/agents/components/create-edit-agent-dialogs/mcp-server-selector/index.tsx
+++ /dev/null
@@ -1,207 +0,0 @@
-"use client";
-
-import { Checkbox } from "@/components/ui/checkbox";
-import { Badge } from "@/components/ui/badge";
-import { Skeleton } from "@/components/ui/skeleton";
-import {
- Collapsible,
- CollapsibleContent,
- CollapsibleTrigger,
-} from "@/components/ui/collapsible";
-import {
- useMcpServers,
- McpServer,
-} from "@/features/settings/hooks/use-mcp-servers";
-import { useMcpServerTools, Tool } from "./use-mcp-server-tools";
-import { ChevronDown } from "lucide-react";
-import { useEffect } from "react";
-
-// -------------------------------------------------------------------------
-// ServerToolPreview
-// Fetches and renders the tool list for a single selected server.
-// -------------------------------------------------------------------------
-
-interface ServerToolPreviewProps {
- server: McpServer;
- onUnreachable: (id: string) => void;
-}
-
-function ServerToolPreview({ server, onUnreachable }: ServerToolPreviewProps) {
- const { tools, loading, error } = useMcpServerTools(server.id);
-
- useEffect(() => {
- if (error) {
- onUnreachable(server.id);
- }
- }, [error, server.id, onUnreachable]);
-
- if (loading) {
- return (
- Loading tools...
- );
- }
-
- if (error) {
- return (
- Server unreachable
- );
- }
-
- return (
-
-
-
-
- {tools.length} {tools.length === 1 ? "tool" : "tools"}
-
-
-
- {tools.map((tool: Tool) => (
-
-
{tool.name}
- {tool.description && (
-
{tool.description}
- )}
-
- ))}
- {tools.length === 0 && (
- No tools available.
- )}
-
-
- );
-}
-
-// -------------------------------------------------------------------------
-// ServerRow
-// Renders a single server as a checkbox row, with tool preview when checked.
-// -------------------------------------------------------------------------
-
-interface ServerRowProps {
- server: McpServer;
- isSelected: boolean;
- isDisabled: boolean;
- onCheckedChange: (checked: boolean) => void;
- onUnreachable: (id: string) => void;
-}
-
-function ServerRow({
- server,
- isSelected,
- isDisabled,
- onCheckedChange,
- onUnreachable,
-}: ServerRowProps) {
- return (
-
-
-
onCheckedChange(checked === true)}
- />
-
-
-
{server.url}
-
-
- {isSelected && (
-
- )}
-
- );
-}
-
-// -------------------------------------------------------------------------
-// McpServerSelector
-// Main component: checkbox list of available MCP servers with tool preview.
-// -------------------------------------------------------------------------
-
-export interface McpServerSelectorProps {
- selectedServerIds: string[];
- onSelectionChange: (ids: string[]) => void;
-}
-
-export function McpServerSelector({
- selectedServerIds,
- onSelectionChange,
-}: McpServerSelectorProps) {
- const { servers, loading } = useMcpServers();
-
- const handleCheckedChange = (serverId: string, checked: boolean) => {
- if (checked) {
- onSelectionChange([...selectedServerIds, serverId]);
- } else {
- onSelectionChange(selectedServerIds.filter((id) => id !== serverId));
- }
- };
-
- const handleUnreachable = (serverId: string) => {
- // Remove from selection if unreachable
- if (selectedServerIds.includes(serverId)) {
- onSelectionChange(selectedServerIds.filter((id) => id !== serverId));
- }
- };
-
- if (loading) {
- return (
-
- {Array.from({ length: 3 }).map((_, i) => (
-
- ))}
-
- );
- }
-
- if (servers.length === 0) {
- return (
-
- No MCP servers configured. Add servers in Settings.
-
- );
- }
-
- return (
-
- {servers.map((server) => {
- const isSelected = selectedServerIds.includes(server.id);
- return (
-
- handleCheckedChange(server.id, checked)
- }
- onUnreachable={handleUnreachable}
- />
- );
- })}
-
- );
-}
diff --git a/apps/web/src/features/agents/components/create-edit-agent-dialogs/mcp-server-selector/mcp-server-tool-groups.tsx b/apps/web/src/features/agents/components/create-edit-agent-dialogs/mcp-server-selector/mcp-server-tool-groups.tsx
new file mode 100644
index 00000000..478f02eb
--- /dev/null
+++ b/apps/web/src/features/agents/components/create-edit-agent-dialogs/mcp-server-selector/mcp-server-tool-groups.tsx
@@ -0,0 +1,245 @@
+"use client";
+
+import { Badge } from "@/components/ui/badge";
+import { Label } from "@/components/ui/label";
+import { Skeleton } from "@/components/ui/skeleton";
+import { Switch } from "@/components/ui/switch";
+import {
+ Collapsible,
+ CollapsibleContent,
+ CollapsibleTrigger,
+} from "@/components/ui/collapsible";
+import {
+ useMcpServers,
+ McpServer,
+} from "@/features/settings/hooks/use-mcp-servers";
+import { useMcpServerTools } from "./use-mcp-server-tools";
+import { ChevronDown } from "lucide-react";
+import _ from "lodash";
+
+// ---------------------------------------------------------------------------
+// ServerToolList
+// Fetches and renders toggle rows for a single server's tools.
+// ---------------------------------------------------------------------------
+
+interface ServerToolListProps {
+ server: McpServer;
+ selectedTools: string[];
+ onToolToggle: (serverId: string, toolName: string, checked: boolean) => void;
+ searchTerm?: string;
+ tenant?: string;
+}
+
+function ServerToolList({
+ server,
+ selectedTools,
+ onToolToggle,
+ searchTerm,
+ tenant,
+}: ServerToolListProps) {
+ const { tools, loading, error } = useMcpServerTools(server.id, tenant);
+
+ if (loading) {
+ return (
+
+ {Array.from({ length: 3 }).map((_, i) => (
+
+ ))}
+
+ );
+ }
+
+ if (error) {
+ return (
+
+ Server unreachable — tools cannot be loaded.
+
+ );
+ }
+
+ const filteredTools = searchTerm
+ ? tools.filter(
+ (t) =>
+ t.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
+ t.description?.toLowerCase().includes(searchTerm.toLowerCase()),
+ )
+ : tools;
+
+ if (filteredTools.length === 0 && searchTerm) {
+ return null;
+ }
+
+ if (tools.length === 0) {
+ return (
+
+ No tools available on this server.
+
+ );
+ }
+
+ return (
+
+ {filteredTools.map((tool) => {
+ const checked = selectedTools.includes(tool.name);
+ const id = `mcp-tool-${server.id}-${tool.name}`;
+ return (
+
+
+
+
+ onToolToggle(server.id, tool.name, val)
+ }
+ />
+
+ {tool.description && (
+
+ {tool.description}
+
+ )}
+
+ );
+ })}
+
+ );
+}
+
+// ---------------------------------------------------------------------------
+// McpServerToolGroups
+// Renders collapsible groups of MCP server tools with per-tool toggles.
+// ---------------------------------------------------------------------------
+
+export interface McpServerToolGroupsProps {
+ selectedToolsByServer: Record;
+ onSelectionChange: (selection: Record) => void;
+ searchTerm?: string;
+ tenant?: string;
+}
+
+export function McpServerToolGroups({
+ selectedToolsByServer,
+ onSelectionChange,
+ searchTerm,
+ tenant,
+}: McpServerToolGroupsProps) {
+ const { servers, loading } = useMcpServers();
+
+ const handleToolToggle = (
+ serverId: string,
+ toolName: string,
+ checked: boolean,
+ ) => {
+ const current = selectedToolsByServer[serverId] ?? [];
+ const updated = checked
+ ? Array.from(new Set([...current, toolName]))
+ : current.filter((t) => t !== toolName);
+
+ onSelectionChange({
+ ...selectedToolsByServer,
+ [serverId]: updated,
+ });
+ };
+
+ if (loading) {
+ return (
+
+ {Array.from({ length: 2 }).map((_, i) => (
+
+
+
+
+ ))}
+
+ );
+ }
+
+ if (servers.length === 0) {
+ return null;
+ }
+
+ return (
+
+ {servers.map((server: McpServer) => {
+ const selectedTools = selectedToolsByServer[server.id] ?? [];
+ const selectedCount = selectedTools.length;
+
+ return (
+
+ );
+ })}
+
+ );
+}
+
+// ---------------------------------------------------------------------------
+// ServerGroup
+// A single collapsible server group — separated so tool fetching only happens
+// when the group is present (always), but rendering is controlled by collapsible.
+// ---------------------------------------------------------------------------
+
+interface ServerGroupProps {
+ server: McpServer;
+ selectedTools: string[];
+ selectedCount: number;
+ onToolToggle: (serverId: string, toolName: string, checked: boolean) => void;
+ searchTerm?: string;
+ tenant?: string;
+}
+
+function ServerGroup({
+ server,
+ selectedTools,
+ selectedCount,
+ onToolToggle,
+ searchTerm,
+ tenant,
+}: ServerGroupProps) {
+ return (
+
+
+
+ {server.name}
+ {selectedCount > 0 && (
+
+ {selectedCount} selected
+
+ )}
+ {server.isDefault && (
+ 0 ? "" : "ml-auto"}>
+ Default
+
+ )}
+
+
+
+
+
+ );
+}
diff --git a/apps/web/src/features/agents/components/create-edit-agent-dialogs/mcp-server-selector/use-mcp-server-tools.tsx b/apps/web/src/features/agents/components/create-edit-agent-dialogs/mcp-server-selector/use-mcp-server-tools.tsx
index b223ad27..7061bfaa 100644
--- a/apps/web/src/features/agents/components/create-edit-agent-dialogs/mcp-server-selector/use-mcp-server-tools.tsx
+++ b/apps/web/src/features/agents/components/create-edit-agent-dialogs/mcp-server-selector/use-mcp-server-tools.tsx
@@ -22,6 +22,7 @@ interface UseMcpServerToolsReturn {
*/
export function useMcpServerTools(
serverId: string | null,
+ tenant?: string,
): UseMcpServerToolsReturn {
const [tools, setTools] = useState([]);
const [loading, setLoading] = useState(false);
@@ -47,7 +48,10 @@ export function useMcpServerTools(
setError(null);
try {
- const res = await fetch(`/api/mcp-servers/${serverId}/tools`);
+ const url = tenant
+ ? `/api/mcp-servers/${serverId}/tools?tenant=${encodeURIComponent(tenant)}`
+ : `/api/mcp-servers/${serverId}/tools`;
+ const res = await fetch(url);
if (aborted) return;
if (!res.ok) {
@@ -83,7 +87,7 @@ export function useMcpServerTools(
return () => {
aborted = true;
};
- }, [serverId, fetchCounter]);
+ }, [serverId, tenant, fetchCounter]);
return { tools, loading, error, refetch };
}
From 0ae7ec77e6e18bc8f2cd38d19e91a91ad133ac2c Mon Sep 17 00:00:00 2001
From: fjunqueira
Date: Fri, 27 Feb 2026 13:04:28 -0300
Subject: [PATCH 15/20] feat(mcp): add Cognito auth + tenant scoping to MCP
server endpoints
Add JWT validation and tenant isolation to all MCP server API routes so
each tenant sees only its own servers while default servers remain global.
- Create shared requireAuth() helper (validates Bearer token via Cognito,
extracts x-tenant-name header)
- Add tenantName field to McpServer model with compound index
- Scope all CRUD and snapshot queries by tenantName (cross-tenant access
returns 404 to avoid leaking existence)
- Send Authorization + x-tenant-name headers from all frontend hooks
(useMcpServers, useMcpServerTools, agent create/edit snapshot fetches)
Co-Authored-By: Claude Opus 4.6
---
.../web/src/app/api/mcp-servers/[id]/route.ts | 23 ++++++---
.../app/api/mcp-servers/[id]/tools/route.ts | 13 +++--
apps/web/src/app/api/mcp-servers/route.ts | 12 ++++-
.../src/app/api/mcp-servers/snapshot/route.ts | 5 ++
.../create-agent-dialog.tsx | 15 +++++-
.../edit-agent-dialog.tsx | 15 +++++-
.../use-mcp-server-tools.tsx | 17 ++++++-
.../settings/hooks/use-mcp-servers.tsx | 39 +++++++++++----
apps/web/src/lib/auth/require-auth.ts | 48 +++++++++++++++++++
apps/web/src/models/mcp-server.ts | 4 ++
10 files changed, 164 insertions(+), 27 deletions(-)
create mode 100644 apps/web/src/lib/auth/require-auth.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
index 17c73e3d..67c43e6a 100644
--- a/apps/web/src/app/api/mcp-servers/[id]/route.ts
+++ b/apps/web/src/app/api/mcp-servers/[id]/route.ts
@@ -4,6 +4,7 @@ import { z } from "zod";
import { connectDB } from "@/lib/mongodb";
import McpServer from "@/models/mcp-server";
import { encrypt, decrypt, maskCredential } from "@/lib/encryption";
+import { requireAuth } from "@/lib/auth/require-auth";
const DEFAULT_SERVER_IDS = ["default-typebot", "default-cloudhumans"];
@@ -51,6 +52,9 @@ export async function PUT(
req: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
+ const auth = await requireAuth(req);
+ if (!auth.ok) return auth.response;
+
try {
const { id } = await params;
@@ -100,10 +104,11 @@ export async function PUT(
: null;
}
- const updated = await McpServer.findByIdAndUpdate(id, updateData, {
- new: true,
- runValidators: true,
- }).lean();
+ const updated = await McpServer.findOneAndUpdate(
+ { _id: id, tenantName: auth.tenantName },
+ updateData,
+ { new: true, runValidators: true },
+ ).lean();
if (!updated) {
return Response.json({ error: "Server not found" }, { status: 404 });
@@ -136,9 +141,12 @@ export async function PUT(
}
export async function DELETE(
- _req: NextRequest,
+ req: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
+ const auth = await requireAuth(req);
+ if (!auth.ok) return auth.response;
+
try {
const { id } = await params;
@@ -162,7 +170,10 @@ export async function DELETE(
);
}
- const deleted = await McpServer.findByIdAndDelete(id).lean();
+ const deleted = await McpServer.findOneAndDelete({
+ _id: id,
+ tenantName: auth.tenantName,
+ }).lean();
if (!deleted) {
return Response.json({ error: "Server not found" }, { status: 404 });
diff --git a/apps/web/src/app/api/mcp-servers/[id]/tools/route.ts b/apps/web/src/app/api/mcp-servers/[id]/tools/route.ts
index 4891462a..74394ace 100644
--- a/apps/web/src/app/api/mcp-servers/[id]/tools/route.ts
+++ b/apps/web/src/app/api/mcp-servers/[id]/tools/route.ts
@@ -6,14 +6,18 @@ import { connectDB } from "@/lib/mongodb";
import { decrypt } from "@/lib/encryption";
import { getDefaultServers } from "@/lib/mcp-defaults";
import McpServer from "@/models/mcp-server";
+import { requireAuth } from "@/lib/auth/require-auth";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export async function GET(
- _req: NextRequest,
+ req: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
+ const auth = await requireAuth(req);
+ if (!auth.ok) return auth.response;
+
const { id } = await params;
let serverUrl: string;
@@ -36,7 +40,10 @@ export async function GET(
await connectDB();
- const doc = await McpServer.findById(id).lean();
+ const doc = await McpServer.findOne({
+ _id: id,
+ tenantName: auth.tenantName,
+ }).lean();
if (!doc) {
return Response.json({ error: "Server not found" }, { status: 404 });
@@ -61,7 +68,7 @@ export async function GET(
}
// Forward tenant header when present
- const tenant = _req.nextUrl.searchParams.get("tenant");
+ const tenant = req.nextUrl.searchParams.get("tenant");
if (tenant) {
headers["x-tenant"] = tenant;
}
diff --git a/apps/web/src/app/api/mcp-servers/route.ts b/apps/web/src/app/api/mcp-servers/route.ts
index a6658520..a4f7c911 100644
--- a/apps/web/src/app/api/mcp-servers/route.ts
+++ b/apps/web/src/app/api/mcp-servers/route.ts
@@ -5,6 +5,7 @@ import { connectDB } from "@/lib/mongodb";
import McpServer from "@/models/mcp-server";
import { getDefaultServers } from "@/lib/mcp-defaults";
import { encrypt, decrypt, maskCredential } from "@/lib/encryption";
+import { requireAuth } from "@/lib/auth/require-auth";
const CreateMcpServerSchema = z
.object({
@@ -35,7 +36,10 @@ const CreateMcpServerSchema = z
}
});
-export async function GET() {
+export async function GET(req: NextRequest) {
+ const auth = await requireAuth(req);
+ if (!auth.ok) return auth.response;
+
const defaults = getDefaultServers().map((server) => ({
...server,
credentials: maskCredential(server.credentials),
@@ -55,7 +59,7 @@ export async function GET() {
try {
await connectDB();
- const docs = await McpServer.find({}).lean();
+ const docs = await McpServer.find({ tenantName: auth.tenantName }).lean();
for (const doc of docs) {
userServers.push({
@@ -84,6 +88,9 @@ export async function GET() {
}
export async function POST(req: NextRequest) {
+ const auth = await requireAuth(req);
+ if (!auth.ok) return auth.response;
+
try {
const body = await req.json();
const parsed = CreateMcpServerSchema.safeParse(body);
@@ -111,6 +118,7 @@ export async function POST(req: NextRequest) {
const doc = await McpServer.create({
...parsed.data,
credentials: encryptedCreds,
+ tenantName: auth.tenantName,
});
return Response.json(
diff --git a/apps/web/src/app/api/mcp-servers/snapshot/route.ts b/apps/web/src/app/api/mcp-servers/snapshot/route.ts
index 645b382d..5a442df5 100644
--- a/apps/web/src/app/api/mcp-servers/snapshot/route.ts
+++ b/apps/web/src/app/api/mcp-servers/snapshot/route.ts
@@ -4,6 +4,7 @@ import { connectDB } from "@/lib/mongodb";
import { decrypt } from "@/lib/encryption";
import { getDefaultServers } from "@/lib/mcp-defaults";
import McpServer from "@/models/mcp-server";
+import { requireAuth } from "@/lib/auth/require-auth";
export const runtime = "nodejs";
@@ -15,6 +16,9 @@ interface ServerSnapshot {
}
export async function GET(req: NextRequest) {
+ const auth = await requireAuth(req);
+ if (!auth.ok) return auth.response;
+
const { searchParams } = new URL(req.url);
const ids = searchParams.getAll("ids[]");
@@ -69,6 +73,7 @@ export async function GET(req: NextRequest) {
const docs = await McpServer.find({
_id: { $in: validIds },
+ tenantName: auth.tenantName,
}).lean();
for (const doc of docs) {
diff --git a/apps/web/src/features/agents/components/create-edit-agent-dialogs/create-agent-dialog.tsx b/apps/web/src/features/agents/components/create-edit-agent-dialogs/create-agent-dialog.tsx
index 9cff2418..7b3eaa52 100644
--- a/apps/web/src/features/agents/components/create-edit-agent-dialogs/create-agent-dialog.tsx
+++ b/apps/web/src/features/agents/components/create-edit-agent-dialogs/create-agent-dialog.tsx
@@ -22,6 +22,7 @@ import { GraphSelect } from "./graph-select";
import { useAgentConfig } from "@/hooks/use-agent-config";
import { FormProvider, useForm } from "react-hook-form";
import { useMcpServers } from "@/features/settings/hooks/use-mcp-servers";
+import { useAuthContext } from "@/providers/Auth";
interface CreateAgentDialogProps {
agentId?: string;
@@ -47,7 +48,8 @@ function CreateAgentFormContent(props: {
agentsConfigurations,
hasMcpServers,
} = useAgentConfig();
- const { selectedTenant } = useTenantContext();
+ const { selectedTenant, selectedTenantId } = useTenantContext();
+ const { session } = useAuthContext();
const { servers: availableServers } = useMcpServers();
const [submitting, setSubmitting] = useState(false);
// New agents start with no MCP tools selected
@@ -92,7 +94,16 @@ function CreateAgentFormContent(props: {
if (serverIdsWithTools.length > 0) {
// Fetch decrypted server snapshots for selected servers
const qs = serverIdsWithTools.map((id) => `ids[]=${encodeURIComponent(id)}`).join("&");
- const snapshotRes = await fetch(`/api/mcp-servers/snapshot?${qs}`);
+ const snapshotHeaders: HeadersInit = {};
+ if (session?.accessToken) {
+ snapshotHeaders["Authorization"] = `Bearer ${session.accessToken}`;
+ }
+ if (selectedTenantId) {
+ snapshotHeaders["x-tenant-name"] = selectedTenantId;
+ }
+ const snapshotRes = await fetch(`/api/mcp-servers/snapshot?${qs}`, {
+ headers: snapshotHeaders,
+ });
if (!snapshotRes.ok) {
toast.error("Failed to fetch MCP server configuration", {
description: "Please try again",
diff --git a/apps/web/src/features/agents/components/create-edit-agent-dialogs/edit-agent-dialog.tsx b/apps/web/src/features/agents/components/create-edit-agent-dialogs/edit-agent-dialog.tsx
index e153718d..fb87260c 100644
--- a/apps/web/src/features/agents/components/create-edit-agent-dialogs/edit-agent-dialog.tsx
+++ b/apps/web/src/features/agents/components/create-edit-agent-dialogs/edit-agent-dialog.tsx
@@ -21,6 +21,7 @@ import { FormProvider, useForm } from "react-hook-form";
import { hasStaleSupervisors } from "@/lib/agent-utils";
import { StaleSupervisorsWarningDialog } from "./stale-supervisors-warning-dialog";
import { useMcpServers } from "@/features/settings/hooks/use-mcp-servers";
+import { useAuthContext } from "@/providers/Auth";
interface EditAgentDialogProps {
agent: Agent;
@@ -49,7 +50,8 @@ function EditAgentDialogContent({
agentsConfigurations,
hasMcpServers,
} = useAgentConfig();
- const { selectedTenant } = useTenantContext();
+ const { selectedTenant, selectedTenantId } = useTenantContext();
+ const { session } = useAuthContext();
const [deleteSubmitting, setDeleteSubmitting] = useState(false);
const [selectedToolsByServer, setSelectedToolsByServer] = useState>({});
@@ -117,7 +119,16 @@ function EditAgentDialogContent({
if (serverIdsWithTools.length > 0) {
// Fetch decrypted server snapshots for selected servers
const qs = serverIdsWithTools.map((id) => `ids[]=${encodeURIComponent(id)}`).join("&");
- const snapshotRes = await fetch(`/api/mcp-servers/snapshot?${qs}`);
+ const snapshotHeaders: HeadersInit = {};
+ if (session?.accessToken) {
+ snapshotHeaders["Authorization"] = `Bearer ${session.accessToken}`;
+ }
+ if (selectedTenantId) {
+ snapshotHeaders["x-tenant-name"] = selectedTenantId;
+ }
+ const snapshotRes = await fetch(`/api/mcp-servers/snapshot?${qs}`, {
+ headers: snapshotHeaders,
+ });
if (!snapshotRes.ok) {
toast.error("Failed to fetch MCP server configuration", {
description: "Please try again",
diff --git a/apps/web/src/features/agents/components/create-edit-agent-dialogs/mcp-server-selector/use-mcp-server-tools.tsx b/apps/web/src/features/agents/components/create-edit-agent-dialogs/mcp-server-selector/use-mcp-server-tools.tsx
index 7061bfaa..2baaac41 100644
--- a/apps/web/src/features/agents/components/create-edit-agent-dialogs/mcp-server-selector/use-mcp-server-tools.tsx
+++ b/apps/web/src/features/agents/components/create-edit-agent-dialogs/mcp-server-selector/use-mcp-server-tools.tsx
@@ -1,6 +1,8 @@
"use client";
import { useState, useEffect, useCallback } from "react";
+import { useAuthContext } from "@/providers/Auth";
+import { useTenantContext } from "@/providers/Tenant";
export interface Tool {
name: string;
@@ -24,6 +26,8 @@ export function useMcpServerTools(
serverId: string | null,
tenant?: string,
): UseMcpServerToolsReturn {
+ const { session } = useAuthContext();
+ const { selectedTenantId } = useTenantContext();
const [tools, setTools] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
@@ -51,7 +55,16 @@ export function useMcpServerTools(
const url = tenant
? `/api/mcp-servers/${serverId}/tools?tenant=${encodeURIComponent(tenant)}`
: `/api/mcp-servers/${serverId}/tools`;
- const res = await fetch(url);
+
+ const headers: HeadersInit = {};
+ if (session?.accessToken) {
+ headers["Authorization"] = `Bearer ${session.accessToken}`;
+ }
+ if (selectedTenantId) {
+ headers["x-tenant-name"] = selectedTenantId;
+ }
+
+ const res = await fetch(url, { headers });
if (aborted) return;
if (!res.ok) {
@@ -87,7 +100,7 @@ export function useMcpServerTools(
return () => {
aborted = true;
};
- }, [serverId, tenant, fetchCounter]);
+ }, [serverId, tenant, fetchCounter, session?.accessToken, selectedTenantId]);
return { tools, loading, error, refetch };
}
diff --git a/apps/web/src/features/settings/hooks/use-mcp-servers.tsx b/apps/web/src/features/settings/hooks/use-mcp-servers.tsx
index af2dd631..94ad4c75 100644
--- a/apps/web/src/features/settings/hooks/use-mcp-servers.tsx
+++ b/apps/web/src/features/settings/hooks/use-mcp-servers.tsx
@@ -2,6 +2,8 @@
import { useState, useCallback, useEffect } from "react";
import { toast } from "sonner";
+import { useAuthContext } from "@/providers/Auth";
+import { useTenantContext } from "@/providers/Tenant";
export interface McpServer {
id: string;
@@ -27,15 +29,29 @@ interface UseMcpServersReturn {
}
export function useMcpServers(): UseMcpServersReturn {
+ const { session } = useAuthContext();
+ const { selectedTenantId } = useTenantContext();
const [servers, setServers] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
+ const getAuthHeaders = useCallback((): HeadersInit => {
+ const headers: HeadersInit = { "Content-Type": "application/json" };
+ if (session?.accessToken) {
+ headers["Authorization"] = `Bearer ${session.accessToken}`;
+ }
+ if (selectedTenantId) {
+ headers["x-tenant-name"] = selectedTenantId;
+ }
+ return headers;
+ }, [session?.accessToken, selectedTenantId]);
+
const fetchServers = useCallback(async () => {
+ if (!selectedTenantId) return;
setLoading(true);
setError(null);
try {
- const res = await fetch("/api/mcp-servers");
+ const res = await fetch("/api/mcp-servers", { headers: getAuthHeaders() });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
setServers(data.servers ?? []);
@@ -46,7 +62,7 @@ export function useMcpServers(): UseMcpServersReturn {
} finally {
setLoading(false);
}
- }, []);
+ }, [getAuthHeaders, selectedTenantId]);
useEffect(() => {
fetchServers();
@@ -57,7 +73,7 @@ export function useMcpServers(): UseMcpServersReturn {
try {
const res = await fetch("/api/mcp-servers", {
method: "POST",
- headers: { "Content-Type": "application/json" },
+ headers: getAuthHeaders(),
body: JSON.stringify(body),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
@@ -69,7 +85,7 @@ export function useMcpServers(): UseMcpServersReturn {
toast.error("Failed to add server");
}
},
- [],
+ [getAuthHeaders],
);
const updateServer = useCallback(
@@ -77,7 +93,7 @@ export function useMcpServers(): UseMcpServersReturn {
try {
const res = await fetch(`/api/mcp-servers/${id}`, {
method: "PUT",
- headers: { "Content-Type": "application/json" },
+ headers: getAuthHeaders(),
body: JSON.stringify(body),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
@@ -89,12 +105,15 @@ export function useMcpServers(): UseMcpServersReturn {
toast.error("Failed to update server");
}
},
- [],
+ [getAuthHeaders],
);
const deleteServer = useCallback(async (id: string) => {
try {
- const res = await fetch(`/api/mcp-servers/${id}`, { method: "DELETE" });
+ const res = await fetch(`/api/mcp-servers/${id}`, {
+ method: "DELETE",
+ headers: getAuthHeaders(),
+ });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
setServers((prev) => prev.filter((s) => s.id !== id));
toast.success("Server deleted");
@@ -102,7 +121,7 @@ export function useMcpServers(): UseMcpServersReturn {
console.error("[useMcpServers] Failed to delete:", err);
toast.error("Failed to delete server");
}
- }, []);
+ }, [getAuthHeaders]);
const toggleServer = useCallback(async (id: string, enabled: boolean) => {
// Optimistic update
@@ -112,7 +131,7 @@ export function useMcpServers(): UseMcpServersReturn {
try {
const res = await fetch(`/api/mcp-servers/${id}`, {
method: "PUT",
- headers: { "Content-Type": "application/json" },
+ headers: getAuthHeaders(),
body: JSON.stringify({ enabled }),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
@@ -124,7 +143,7 @@ export function useMcpServers(): UseMcpServersReturn {
);
toast.error("Failed to update server");
}
- }, []);
+ }, [getAuthHeaders]);
return {
servers,
diff --git a/apps/web/src/lib/auth/require-auth.ts b/apps/web/src/lib/auth/require-auth.ts
new file mode 100644
index 00000000..b3b7e219
--- /dev/null
+++ b/apps/web/src/lib/auth/require-auth.ts
@@ -0,0 +1,48 @@
+import { NextRequest } from "next/server";
+import { verifyCognitoToken } from "./cognito-server";
+
+type AuthResult =
+ | { ok: true; tenantName: string }
+ | { ok: false; response: Response };
+
+/**
+ * Validate Cognito JWT from Authorization header and extract x-tenant-name.
+ * Returns the tenantName on success, or a pre-built error Response on failure.
+ */
+export async function requireAuth(req: NextRequest): Promise {
+ const authHeader = req.headers.get("authorization");
+ if (!authHeader || !authHeader.startsWith("Bearer ")) {
+ return {
+ ok: false,
+ response: Response.json(
+ { error: "Unauthorized", message: "Bearer token required" },
+ { status: 401 },
+ ),
+ };
+ }
+
+ const token = authHeader.slice(7);
+ const valid = await verifyCognitoToken(token);
+ if (!valid) {
+ return {
+ ok: false,
+ response: Response.json(
+ { error: "Unauthorized", message: "Invalid or expired token" },
+ { status: 401 },
+ ),
+ };
+ }
+
+ const tenantName = req.headers.get("x-tenant-name");
+ if (!tenantName) {
+ return {
+ ok: false,
+ response: Response.json(
+ { error: "Bad Request", message: "x-tenant-name header is required" },
+ { status: 400 },
+ ),
+ };
+ }
+
+ return { ok: true, tenantName };
+}
diff --git a/apps/web/src/models/mcp-server.ts b/apps/web/src/models/mcp-server.ts
index 08cf4db6..d69c5322 100644
--- a/apps/web/src/models/mcp-server.ts
+++ b/apps/web/src/models/mcp-server.ts
@@ -6,6 +6,7 @@ export interface IMcpServer extends Document {
authType: "none" | "bearer" | "apiKey";
credentials: string | null;
enabled: boolean;
+ tenantName: string;
createdAt: Date;
updatedAt: Date;
}
@@ -21,6 +22,7 @@ const McpServerSchema = new Schema(
},
credentials: { type: String, default: null },
enabled: { type: Boolean, default: true },
+ tenantName: { type: String, required: true },
},
{
timestamps: true,
@@ -28,6 +30,8 @@ const McpServerSchema = new Schema(
},
);
+McpServerSchema.index({ tenantName: 1, name: 1 });
+
const McpServer =
(mongoose.models.McpServer as mongoose.Model) ||
mongoose.model("McpServer", McpServerSchema);
From 75e58b13c1f3262caa62aa3d86683e9df6f07f42 Mon Sep 17 00:00:00 2001
From: fjunqueira
Date: Fri, 27 Feb 2026 14:17:07 -0300
Subject: [PATCH 16/20] fix(mcp): ID-based matching, disabled filter, dedup
hooks, prod guard, cleanup
- Snapshot endpoint now returns `id` field for stable server matching
- Edit/create dialogs match by `id` first, fall back to `name`
- McpServerToolGroups filters out disabled servers and accepts servers
as props (removes duplicate useMcpServers() call)
- Thread mcpServers/mcpServersLoading from dialogs through AgentFieldsForm
- Hard-error in production when MCP_ENCRYPTION_KEY is missing
- Document new env vars (MONGODB_URI, MCP_ENCRYPTION_KEY, default servers)
- Remove unused checkbox.tsx and @radix-ui/react-checkbox dependency
Co-Authored-By: Claude Opus 4.6
---
apps/web/.env.example | 14 +++++++-
apps/web/package.json | 1 -
.../src/app/api/mcp-servers/snapshot/route.ts | 3 ++
apps/web/src/components/ui/checkbox.tsx | 33 -------------------
.../create-edit-agent-dialogs/agent-form.tsx | 7 ++++
.../create-agent-dialog.tsx | 9 +++--
.../edit-agent-dialog.tsx | 16 ++++++---
.../mcp-server-tool-groups.tsx | 11 ++++---
apps/web/src/lib/encryption.ts | 5 +++
9 files changed, 53 insertions(+), 46 deletions(-)
delete mode 100644 apps/web/src/components/ui/checkbox.tsx
diff --git a/apps/web/.env.example b/apps/web/.env.example
index c5debf38..6b5c4f89 100644
--- a/apps/web/.env.example
+++ b/apps/web/.env.example
@@ -34,4 +34,16 @@ BACKOFFICE_API_URL="http://localhost:8001/api/"
OAP_BACKEND_COGNITO_APP_CLIENT_TOKEN_URL=""
OAP_BACKEND_COGNITO_APP_CLIENT_TOKEN_SCOPE=""
OAP_BACKEND_COGNITO_APP_CLIENT_ID=""
-OAP_BACKEND_COGNITO_APP_CLIENT_SECRET=""
\ No newline at end of file
+OAP_BACKEND_COGNITO_APP_CLIENT_SECRET=""
+
+# Multi-MCP server configuration
+# MongoDB connection string for MCP server registry
+MONGODB_URI=""
+# 64-char hex string (32 bytes) for AES-256-GCM credential encryption.
+# Required in production; uses insecure fallback in development if not set.
+MCP_ENCRYPTION_KEY=""
+# Default MCP servers (optional — pre-populated in the server list)
+MCP_TYPEBOT_URL=""
+MCP_TYPEBOT_BEARER_TOKEN=""
+MCP_CLOUDHUMANS_URL=""
+MCP_CLOUDHUMANS_BEARER_TOKEN=""
\ No newline at end of file
diff --git a/apps/web/package.json b/apps/web/package.json
index 9291ccce..9ba020a3 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -19,7 +19,6 @@
"@modelcontextprotocol/sdk": "^1.11.4",
"@radix-ui/react-alert-dialog": "^1.1.11",
"@radix-ui/react-avatar": "^1.1.4",
- "@radix-ui/react-checkbox": "1.1.2",
"@radix-ui/react-collapsible": "^1.1.4",
"@radix-ui/react-dialog": "^1.1.7",
"@radix-ui/react-dropdown-menu": "^2.1.7",
diff --git a/apps/web/src/app/api/mcp-servers/snapshot/route.ts b/apps/web/src/app/api/mcp-servers/snapshot/route.ts
index 5a442df5..4780a69d 100644
--- a/apps/web/src/app/api/mcp-servers/snapshot/route.ts
+++ b/apps/web/src/app/api/mcp-servers/snapshot/route.ts
@@ -9,6 +9,7 @@ import { requireAuth } from "@/lib/auth/require-auth";
export const runtime = "nodejs";
interface ServerSnapshot {
+ id: string;
name: string;
url: string;
authType: "none" | "bearer" | "apiKey";
@@ -47,6 +48,7 @@ export async function GET(req: NextRequest) {
const server = defaults.find((s) => s.id === id);
if (server) {
results.push({
+ id: server.id,
name: server.name,
url: server.url,
authType: server.authType,
@@ -81,6 +83,7 @@ export async function GET(req: NextRequest) {
doc.credentials != null ? decrypt(doc.credentials) : null;
results.push({
+ id: doc._id.toString(),
name: doc.name,
url: doc.url,
authType: doc.authType,
diff --git a/apps/web/src/components/ui/checkbox.tsx b/apps/web/src/components/ui/checkbox.tsx
deleted file mode 100644
index 0f7d09d2..00000000
--- a/apps/web/src/components/ui/checkbox.tsx
+++ /dev/null
@@ -1,33 +0,0 @@
-"use client";
-
-import * as React from "react";
-import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
-import { Check } from "lucide-react";
-
-import { cn } from "@/lib/utils";
-
-function Checkbox({
- className,
- ...props
-}: React.ComponentProps) {
- return (
-
-
-
-
-
- );
-}
-
-export { Checkbox };
diff --git a/apps/web/src/features/agents/components/create-edit-agent-dialogs/agent-form.tsx b/apps/web/src/features/agents/components/create-edit-agent-dialogs/agent-form.tsx
index cd5aaa18..016af8cd 100644
--- a/apps/web/src/features/agents/components/create-edit-agent-dialogs/agent-form.tsx
+++ b/apps/web/src/features/agents/components/create-edit-agent-dialogs/agent-form.tsx
@@ -24,6 +24,7 @@ import _ from "lodash";
import { useFetchPreselectedTools } from "@/hooks/use-fetch-preselected-tools";
import { Controller, useFormContext } from "react-hook-form";
import { McpServerToolGroups } from "./mcp-server-selector/mcp-server-tool-groups";
+import { McpServer } from "@/features/settings/hooks/use-mcp-servers";
export function AgentFieldsFormLoading() {
return (
@@ -48,6 +49,8 @@ interface AgentFieldsFormProps {
ragConfigurations: ConfigurableFieldRAGMetadata[];
agentsConfigurations: ConfigurableFieldAgentsMetadata[];
hasMcpServers?: boolean;
+ mcpServers?: McpServer[];
+ mcpServersLoading?: boolean;
selectedToolsByServer?: Record;
onMcpToolSelectionChange?: (selection: Record) => void;
tenant?: string;
@@ -60,6 +63,8 @@ export function AgentFieldsForm({
ragConfigurations,
agentsConfigurations,
hasMcpServers = false,
+ mcpServers = [],
+ mcpServersLoading = false,
selectedToolsByServer = {},
onMcpToolSelectionChange,
tenant,
@@ -276,6 +281,8 @@ export function AgentFieldsForm({
)}
{hasMcpServers && onMcpToolSelectionChange && (
>({});
@@ -115,7 +115,10 @@ function CreateAgentFormContent(props: {
// Augment each snapshot with its selected tools array
const servers = (snapshotData.servers ?? []) as Record[];
mcpServersPayload = servers.map((snap) => {
- const server = availableServers.find((s) => s.name === (snap as { name?: string }).name);
+ const snapTyped = snap as { id?: string; name?: string };
+ const server =
+ (snapTyped.id && availableServers.find((s) => s.id === snapTyped.id)) ||
+ availableServers.find((s) => s.name === snapTyped.name);
return {
...snap,
tools: server ? (selectedToolsByServer[server.id] ?? []) : [],
@@ -179,6 +182,8 @@ function CreateAgentFormContent(props: {
ragConfigurations={ragConfigurations}
agentsConfigurations={agentsConfigurations}
hasMcpServers={hasMcpServers}
+ mcpServers={availableServers}
+ mcpServersLoading={serversLoading}
selectedToolsByServer={selectedToolsByServer}
onMcpToolSelectionChange={setSelectedToolsByServer}
tenant={selectedTenant?.tenantName}
diff --git a/apps/web/src/features/agents/components/create-edit-agent-dialogs/edit-agent-dialog.tsx b/apps/web/src/features/agents/components/create-edit-agent-dialogs/edit-agent-dialog.tsx
index fb87260c..50613096 100644
--- a/apps/web/src/features/agents/components/create-edit-agent-dialogs/edit-agent-dialog.tsx
+++ b/apps/web/src/features/agents/components/create-edit-agent-dialogs/edit-agent-dialog.tsx
@@ -56,7 +56,7 @@ function EditAgentDialogContent({
const [selectedToolsByServer, setSelectedToolsByServer] = useState>({});
// For pre-populating selected servers on edit: match existing snapshot names to current server IDs
- const { servers: availableServers } = useMcpServers();
+ const { servers: availableServers, loading: serversLoading } = useMcpServers();
const form = useForm<{
name: string;
@@ -83,11 +83,14 @@ function EditAgentDialogContent({
initializedRef.current = true;
const rawSnapshot = (agent.config?.configurable?.mcp_servers ?? []) as unknown;
- const existingSnapshot: { name?: string; tools?: string[] }[] = Array.isArray(rawSnapshot) ? rawSnapshot : [];
+ const existingSnapshot: { id?: string; name?: string; tools?: string[] }[] = Array.isArray(rawSnapshot) ? rawSnapshot : [];
if (existingSnapshot.length > 0) {
const toolsByServer: Record = {};
for (const snap of existingSnapshot) {
- const server = availableServers.find((s) => s.name === snap.name);
+ // Match by id first, fall back to name (for snapshots saved before id was added)
+ const server =
+ (snap.id && availableServers.find((s) => s.id === snap.id)) ||
+ availableServers.find((s) => s.name === snap.name);
if (server && Array.isArray(snap.tools) && snap.tools.length > 0) {
toolsByServer[server.id] = snap.tools;
}
@@ -139,7 +142,10 @@ function EditAgentDialogContent({
// Augment each snapshot with its selected tools array
const servers = (snapshotData.servers ?? []) as Record[];
mcpServersPayload = servers.map((snap) => {
- const server = availableServers.find((s) => s.name === (snap as { name?: string }).name);
+ const snapTyped = snap as { id?: string; name?: string };
+ const server =
+ (snapTyped.id && availableServers.find((s) => s.id === snapTyped.id)) ||
+ availableServers.find((s) => s.name === snapTyped.name);
return {
...snap,
tools: server ? (selectedToolsByServer[server.id] ?? []) : [],
@@ -234,6 +240,8 @@ function EditAgentDialogContent({
ragConfigurations={ragConfigurations}
agentsConfigurations={agentsConfigurations}
hasMcpServers={hasMcpServers}
+ mcpServers={availableServers}
+ mcpServersLoading={serversLoading}
selectedToolsByServer={selectedToolsByServer}
onMcpToolSelectionChange={setSelectedToolsByServer}
tenant={selectedTenant?.tenantName}
diff --git a/apps/web/src/features/agents/components/create-edit-agent-dialogs/mcp-server-selector/mcp-server-tool-groups.tsx b/apps/web/src/features/agents/components/create-edit-agent-dialogs/mcp-server-selector/mcp-server-tool-groups.tsx
index 478f02eb..34828eb2 100644
--- a/apps/web/src/features/agents/components/create-edit-agent-dialogs/mcp-server-selector/mcp-server-tool-groups.tsx
+++ b/apps/web/src/features/agents/components/create-edit-agent-dialogs/mcp-server-selector/mcp-server-tool-groups.tsx
@@ -9,10 +9,7 @@ import {
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
-import {
- useMcpServers,
- McpServer,
-} from "@/features/settings/hooks/use-mcp-servers";
+import { McpServer } from "@/features/settings/hooks/use-mcp-servers";
import { useMcpServerTools } from "./use-mcp-server-tools";
import { ChevronDown } from "lucide-react";
import _ from "lodash";
@@ -123,6 +120,8 @@ function ServerToolList({
// ---------------------------------------------------------------------------
export interface McpServerToolGroupsProps {
+ servers: McpServer[];
+ serversLoading: boolean;
selectedToolsByServer: Record;
onSelectionChange: (selection: Record) => void;
searchTerm?: string;
@@ -130,12 +129,14 @@ export interface McpServerToolGroupsProps {
}
export function McpServerToolGroups({
+ servers: allServers,
+ serversLoading: loading,
selectedToolsByServer,
onSelectionChange,
searchTerm,
tenant,
}: McpServerToolGroupsProps) {
- const { servers, loading } = useMcpServers();
+ const servers = allServers.filter((s) => s.enabled);
const handleToolToggle = (
serverId: string,
diff --git a/apps/web/src/lib/encryption.ts b/apps/web/src/lib/encryption.ts
index ecd599d1..6fda79d8 100644
--- a/apps/web/src/lib/encryption.ts
+++ b/apps/web/src/lib/encryption.ts
@@ -14,6 +14,11 @@ function getKey(): Buffer {
const envVar = process.env.MCP_ENCRYPTION_KEY;
if (!envVar) {
+ if (process.env.NODE_ENV === "production") {
+ throw new Error(
+ "MCP_ENCRYPTION_KEY is required in production. Set a 64-char hex string (32 bytes).",
+ );
+ }
console.warn(
"[MCP] MCP_ENCRYPTION_KEY not set — using insecure dev fallback. Set this env var in production.",
);
From baae2205164675705a8a1f2cb56e3adcae635a71 Mon Sep 17 00:00:00 2001
From: fjunqueira
Date: Fri, 27 Feb 2026 15:29:13 -0300
Subject: [PATCH 17/20] feat: store MCP tool names as slug-prefixed in agent
config
Prefix tool names with server slugs (slug__toolName) on save so
claudia-agentic can filter with a simple Set.has(). Strip prefixes
on load in the edit dialog so the UI still displays bare names.
Co-Authored-By: Claude Opus 4.6
---
.../create-agent-dialog.tsx | 10 ++++++++
.../edit-agent-dialog.tsx | 23 +++++++++++++++++--
apps/web/src/lib/mcp-slug.ts | 19 +++++++++++++++
3 files changed, 50 insertions(+), 2 deletions(-)
create mode 100644 apps/web/src/lib/mcp-slug.ts
diff --git a/apps/web/src/features/agents/components/create-edit-agent-dialogs/create-agent-dialog.tsx b/apps/web/src/features/agents/components/create-edit-agent-dialogs/create-agent-dialog.tsx
index 5223c26b..53d8286f 100644
--- a/apps/web/src/features/agents/components/create-edit-agent-dialogs/create-agent-dialog.tsx
+++ b/apps/web/src/features/agents/components/create-edit-agent-dialogs/create-agent-dialog.tsx
@@ -23,6 +23,7 @@ import { useAgentConfig } from "@/hooks/use-agent-config";
import { FormProvider, useForm } from "react-hook-form";
import { useMcpServers } from "@/features/settings/hooks/use-mcp-servers";
import { useAuthContext } from "@/providers/Auth";
+import { toServerSlug, deduplicateSlugs } from "@/lib/mcp-slug";
interface CreateAgentDialogProps {
agentId?: string;
@@ -124,6 +125,15 @@ function CreateAgentFormContent(props: {
tools: server ? (selectedToolsByServer[server.id] ?? []) : [],
};
});
+
+ // Prefix tool names with server slugs so claudia-agentic can filter with Set.has()
+ const slugs = deduplicateSlugs(
+ (mcpServersPayload as { name?: string }[]).map((s) => toServerSlug(s.name ?? ""))
+ );
+ mcpServersPayload = (mcpServersPayload as Record[]).map((server, i) => ({
+ ...server,
+ tools: ((server.tools as string[]) ?? []).map((t) => `${slugs[i]}__${t}`),
+ }));
} else {
// Explicit empty array — new agent has no MCP servers
mcpServersPayload = [];
diff --git a/apps/web/src/features/agents/components/create-edit-agent-dialogs/edit-agent-dialog.tsx b/apps/web/src/features/agents/components/create-edit-agent-dialogs/edit-agent-dialog.tsx
index 50613096..4d075235 100644
--- a/apps/web/src/features/agents/components/create-edit-agent-dialogs/edit-agent-dialog.tsx
+++ b/apps/web/src/features/agents/components/create-edit-agent-dialogs/edit-agent-dialog.tsx
@@ -22,6 +22,7 @@ import { hasStaleSupervisors } from "@/lib/agent-utils";
import { StaleSupervisorsWarningDialog } from "./stale-supervisors-warning-dialog";
import { useMcpServers } from "@/features/settings/hooks/use-mcp-servers";
import { useAuthContext } from "@/providers/Auth";
+import { toServerSlug, deduplicateSlugs } from "@/lib/mcp-slug";
interface EditAgentDialogProps {
agent: Agent;
@@ -85,14 +86,23 @@ function EditAgentDialogContent({
const rawSnapshot = (agent.config?.configurable?.mcp_servers ?? []) as unknown;
const existingSnapshot: { id?: string; name?: string; tools?: string[] }[] = Array.isArray(rawSnapshot) ? rawSnapshot : [];
if (existingSnapshot.length > 0) {
+ // Compute slugs to strip prefixes from stored tool names
+ const snapshotSlugs = deduplicateSlugs(
+ existingSnapshot.map((s) => toServerSlug(s.name ?? ""))
+ );
const toolsByServer: Record = {};
- for (const snap of existingSnapshot) {
+ for (let i = 0; i < existingSnapshot.length; i++) {
+ const snap = existingSnapshot[i];
+ const slug = snapshotSlugs[i];
+ const prefix = `${slug}__`;
// Match by id first, fall back to name (for snapshots saved before id was added)
const server =
(snap.id && availableServers.find((s) => s.id === snap.id)) ||
availableServers.find((s) => s.name === snap.name);
if (server && Array.isArray(snap.tools) && snap.tools.length > 0) {
- toolsByServer[server.id] = snap.tools;
+ toolsByServer[server.id] = snap.tools.map((t) =>
+ t.startsWith(prefix) ? t.slice(prefix.length) : t
+ );
}
}
if (Object.keys(toolsByServer).length > 0) {
@@ -151,6 +161,15 @@ function EditAgentDialogContent({
tools: server ? (selectedToolsByServer[server.id] ?? []) : [],
};
});
+
+ // Prefix tool names with server slugs so claudia-agentic can filter with Set.has()
+ const slugs = deduplicateSlugs(
+ (mcpServersPayload as { name?: string }[]).map((s) => toServerSlug(s.name ?? ""))
+ );
+ mcpServersPayload = (mcpServersPayload as Record[]).map((server, i) => ({
+ ...server,
+ tools: ((server.tools as string[]) ?? []).map((t) => `${slugs[i]}__${t}`),
+ }));
} else {
// Explicit empty array — agent has no MCP servers assigned
mcpServersPayload = [];
diff --git a/apps/web/src/lib/mcp-slug.ts b/apps/web/src/lib/mcp-slug.ts
new file mode 100644
index 00000000..4bd71b77
--- /dev/null
+++ b/apps/web/src/lib/mcp-slug.ts
@@ -0,0 +1,19 @@
+// MCP server slug utilities — must stay in sync with
+// claudia-agentic/src/packages/src/react_agent/react_agent.ts (toServerSlug, deduplicateSlugs)
+
+export function toServerSlug(name: string): string {
+ return name
+ .toLowerCase()
+ .replace(/[^a-z0-9]+/g, "_")
+ .replace(/^_+|_+$/g, "");
+}
+
+export function deduplicateSlugs(slugs: string[]): string[] {
+ const counts = new Map();
+ return slugs.map((slug) => {
+ const base = slug || "server";
+ const count = (counts.get(base) ?? 0) + 1;
+ counts.set(base, count);
+ return count === 1 ? base : `${base}_${count}`;
+ });
+}
From dc7a03b82125bc5404ee2f33bc1763ca68545085 Mon Sep 17 00:00:00 2001
From: fjunqueira
Date: Fri, 27 Feb 2026 15:58:55 -0300
Subject: [PATCH 18/20] refactor: add slug field to MCP servers, remove
deduplicateSlugs
- Add `slug` field to MongoDB model with unique (tenantName, slug) index
- Compute slug via toServerSlug(name) on create/update, return 409 on
collision (MongoError 11000 or default server slug conflict)
- Add slug to McpServerDefault, McpServer interface, snapshot response
- Remove deduplicateSlugs from mcp-slug.ts (no longer needed)
- Update create/edit agent dialogs to use snap.slug from snapshot for
tool prefixing (edit dialog falls back to toServerSlug for old configs)
Co-Authored-By: Claude Opus 4.6
---
.../web/src/app/api/mcp-servers/[id]/route.ts | 15 ++++++++--
apps/web/src/app/api/mcp-servers/route.ts | 22 +++++++++++++-
.../src/app/api/mcp-servers/snapshot/route.ts | 3 ++
.../create-agent-dialog.tsx | 17 ++++-------
.../edit-agent-dialog.tsx | 30 +++++++------------
.../settings/hooks/use-mcp-servers.tsx | 1 +
apps/web/src/lib/mcp-defaults.ts | 3 ++
apps/web/src/lib/mcp-slug.ts | 12 +-------
apps/web/src/models/mcp-server.ts | 4 ++-
9 files changed, 60 insertions(+), 47 deletions(-)
diff --git a/apps/web/src/app/api/mcp-servers/[id]/route.ts b/apps/web/src/app/api/mcp-servers/[id]/route.ts
index 67c43e6a..cfdd4383 100644
--- a/apps/web/src/app/api/mcp-servers/[id]/route.ts
+++ b/apps/web/src/app/api/mcp-servers/[id]/route.ts
@@ -5,6 +5,7 @@ import { connectDB } from "@/lib/mongodb";
import McpServer from "@/models/mcp-server";
import { encrypt, decrypt, maskCredential } from "@/lib/encryption";
import { requireAuth } from "@/lib/auth/require-auth";
+import { toServerSlug } from "@/lib/mcp-slug";
const DEFAULT_SERVER_IDS = ["default-typebot", "default-cloudhumans"];
@@ -90,7 +91,10 @@ export async function PUT(
const updateData: Record = {};
- if (parsed.data.name !== undefined) updateData.name = parsed.data.name;
+ if (parsed.data.name !== undefined) {
+ updateData.name = parsed.data.name;
+ updateData.slug = toServerSlug(parsed.data.name);
+ }
if (parsed.data.url !== undefined) updateData.url = parsed.data.url;
if (parsed.data.authType !== undefined)
updateData.authType = parsed.data.authType;
@@ -118,6 +122,7 @@ export async function PUT(
{
id: (updated._id as mongoose.Types.ObjectId).toString(),
name: updated.name,
+ slug: updated.slug,
url: updated.url,
authType: updated.authType,
credentials:
@@ -131,7 +136,13 @@ export async function PUT(
},
{ status: 200 },
);
- } catch (error) {
+ } catch (error: any) {
+ if (error?.code === 11000) {
+ return Response.json(
+ { error: "A server with this name already exists" },
+ { status: 409 },
+ );
+ }
console.error("[MCP] Failed to update server:", error);
return Response.json(
{ error: "Failed to update server" },
diff --git a/apps/web/src/app/api/mcp-servers/route.ts b/apps/web/src/app/api/mcp-servers/route.ts
index a4f7c911..4b3e9c63 100644
--- a/apps/web/src/app/api/mcp-servers/route.ts
+++ b/apps/web/src/app/api/mcp-servers/route.ts
@@ -6,6 +6,7 @@ import McpServer from "@/models/mcp-server";
import { getDefaultServers } from "@/lib/mcp-defaults";
import { encrypt, decrypt, maskCredential } from "@/lib/encryption";
import { requireAuth } from "@/lib/auth/require-auth";
+import { toServerSlug } from "@/lib/mcp-slug";
const CreateMcpServerSchema = z
.object({
@@ -48,6 +49,7 @@ export async function GET(req: NextRequest) {
const userServers: Array<{
id: string;
name: string;
+ slug: string;
url: string;
authType: "none" | "bearer" | "apiKey";
credentials: string | null;
@@ -65,6 +67,7 @@ export async function GET(req: NextRequest) {
userServers.push({
id: (doc._id as mongoose.Types.ObjectId).toString(),
name: doc.name,
+ slug: doc.slug,
url: doc.url,
authType: doc.authType,
credentials:
@@ -111,12 +114,22 @@ export async function POST(req: NextRequest) {
);
}
+ const slug = toServerSlug(parsed.data.name);
+ const defaultSlugs = getDefaultServers().map((s) => s.slug);
+ if (defaultSlugs.includes(slug)) {
+ return Response.json(
+ { error: `Slug "${slug}" conflicts with a default server` },
+ { status: 409 },
+ );
+ }
+
const encryptedCreds = parsed.data.credentials
? encrypt(parsed.data.credentials)
: null;
const doc = await McpServer.create({
...parsed.data,
+ slug,
credentials: encryptedCreds,
tenantName: auth.tenantName,
});
@@ -125,6 +138,7 @@ export async function POST(req: NextRequest) {
{
id: doc._id.toString(),
name: doc.name,
+ slug: doc.slug,
url: doc.url,
authType: doc.authType,
credentials:
@@ -138,7 +152,13 @@ export async function POST(req: NextRequest) {
},
{ status: 201 },
);
- } catch (error) {
+ } catch (error: any) {
+ if (error?.code === 11000) {
+ return Response.json(
+ { error: "A server with this name already exists" },
+ { status: 409 },
+ );
+ }
console.error("[MCP] Failed to create server:", error);
return Response.json(
{ error: "Failed to create server" },
diff --git a/apps/web/src/app/api/mcp-servers/snapshot/route.ts b/apps/web/src/app/api/mcp-servers/snapshot/route.ts
index 4780a69d..8af45a09 100644
--- a/apps/web/src/app/api/mcp-servers/snapshot/route.ts
+++ b/apps/web/src/app/api/mcp-servers/snapshot/route.ts
@@ -11,6 +11,7 @@ export const runtime = "nodejs";
interface ServerSnapshot {
id: string;
name: string;
+ slug: string;
url: string;
authType: "none" | "bearer" | "apiKey";
credentials: string | null;
@@ -50,6 +51,7 @@ export async function GET(req: NextRequest) {
results.push({
id: server.id,
name: server.name,
+ slug: server.slug,
url: server.url,
authType: server.authType,
credentials: server.credentials,
@@ -85,6 +87,7 @@ export async function GET(req: NextRequest) {
results.push({
id: doc._id.toString(),
name: doc.name,
+ slug: doc.slug,
url: doc.url,
authType: doc.authType,
credentials: decrypted,
diff --git a/apps/web/src/features/agents/components/create-edit-agent-dialogs/create-agent-dialog.tsx b/apps/web/src/features/agents/components/create-edit-agent-dialogs/create-agent-dialog.tsx
index 53d8286f..34fca3e8 100644
--- a/apps/web/src/features/agents/components/create-edit-agent-dialogs/create-agent-dialog.tsx
+++ b/apps/web/src/features/agents/components/create-edit-agent-dialogs/create-agent-dialog.tsx
@@ -23,7 +23,6 @@ import { useAgentConfig } from "@/hooks/use-agent-config";
import { FormProvider, useForm } from "react-hook-form";
import { useMcpServers } from "@/features/settings/hooks/use-mcp-servers";
import { useAuthContext } from "@/providers/Auth";
-import { toServerSlug, deduplicateSlugs } from "@/lib/mcp-slug";
interface CreateAgentDialogProps {
agentId?: string;
@@ -116,24 +115,18 @@ function CreateAgentFormContent(props: {
// Augment each snapshot with its selected tools array
const servers = (snapshotData.servers ?? []) as Record[];
mcpServersPayload = servers.map((snap) => {
- const snapTyped = snap as { id?: string; name?: string };
+ const snapTyped = snap as { id?: string; name?: string; slug?: string };
const server =
(snapTyped.id && availableServers.find((s) => s.id === snapTyped.id)) ||
availableServers.find((s) => s.name === snapTyped.name);
+ const slug = snapTyped.slug ?? "";
return {
...snap,
- tools: server ? (selectedToolsByServer[server.id] ?? []) : [],
+ tools: (server ? (selectedToolsByServer[server.id] ?? []) : []).map(
+ (t) => `${slug}__${t}`,
+ ),
};
});
-
- // Prefix tool names with server slugs so claudia-agentic can filter with Set.has()
- const slugs = deduplicateSlugs(
- (mcpServersPayload as { name?: string }[]).map((s) => toServerSlug(s.name ?? ""))
- );
- mcpServersPayload = (mcpServersPayload as Record[]).map((server, i) => ({
- ...server,
- tools: ((server.tools as string[]) ?? []).map((t) => `${slugs[i]}__${t}`),
- }));
} else {
// Explicit empty array — new agent has no MCP servers
mcpServersPayload = [];
diff --git a/apps/web/src/features/agents/components/create-edit-agent-dialogs/edit-agent-dialog.tsx b/apps/web/src/features/agents/components/create-edit-agent-dialogs/edit-agent-dialog.tsx
index 4d075235..416c3427 100644
--- a/apps/web/src/features/agents/components/create-edit-agent-dialogs/edit-agent-dialog.tsx
+++ b/apps/web/src/features/agents/components/create-edit-agent-dialogs/edit-agent-dialog.tsx
@@ -22,7 +22,7 @@ import { hasStaleSupervisors } from "@/lib/agent-utils";
import { StaleSupervisorsWarningDialog } from "./stale-supervisors-warning-dialog";
import { useMcpServers } from "@/features/settings/hooks/use-mcp-servers";
import { useAuthContext } from "@/providers/Auth";
-import { toServerSlug, deduplicateSlugs } from "@/lib/mcp-slug";
+import { toServerSlug } from "@/lib/mcp-slug";
interface EditAgentDialogProps {
agent: Agent;
@@ -84,16 +84,12 @@ function EditAgentDialogContent({
initializedRef.current = true;
const rawSnapshot = (agent.config?.configurable?.mcp_servers ?? []) as unknown;
- const existingSnapshot: { id?: string; name?: string; tools?: string[] }[] = Array.isArray(rawSnapshot) ? rawSnapshot : [];
+ const existingSnapshot: { id?: string; name?: string; slug?: string; tools?: string[] }[] = Array.isArray(rawSnapshot) ? rawSnapshot : [];
if (existingSnapshot.length > 0) {
- // Compute slugs to strip prefixes from stored tool names
- const snapshotSlugs = deduplicateSlugs(
- existingSnapshot.map((s) => toServerSlug(s.name ?? ""))
- );
const toolsByServer: Record = {};
- for (let i = 0; i < existingSnapshot.length; i++) {
- const snap = existingSnapshot[i];
- const slug = snapshotSlugs[i];
+ for (const snap of existingSnapshot) {
+ // Use stored slug, fall back to computing from name for old configs
+ const slug = snap.slug ?? toServerSlug(snap.name ?? "");
const prefix = `${slug}__`;
// Match by id first, fall back to name (for snapshots saved before id was added)
const server =
@@ -152,24 +148,18 @@ function EditAgentDialogContent({
// Augment each snapshot with its selected tools array
const servers = (snapshotData.servers ?? []) as Record[];
mcpServersPayload = servers.map((snap) => {
- const snapTyped = snap as { id?: string; name?: string };
+ const snapTyped = snap as { id?: string; name?: string; slug?: string };
const server =
(snapTyped.id && availableServers.find((s) => s.id === snapTyped.id)) ||
availableServers.find((s) => s.name === snapTyped.name);
+ const slug = snapTyped.slug ?? "";
return {
...snap,
- tools: server ? (selectedToolsByServer[server.id] ?? []) : [],
+ tools: (server ? (selectedToolsByServer[server.id] ?? []) : []).map(
+ (t) => `${slug}__${t}`,
+ ),
};
});
-
- // Prefix tool names with server slugs so claudia-agentic can filter with Set.has()
- const slugs = deduplicateSlugs(
- (mcpServersPayload as { name?: string }[]).map((s) => toServerSlug(s.name ?? ""))
- );
- mcpServersPayload = (mcpServersPayload as Record[]).map((server, i) => ({
- ...server,
- tools: ((server.tools as string[]) ?? []).map((t) => `${slugs[i]}__${t}`),
- }));
} else {
// Explicit empty array — agent has no MCP servers assigned
mcpServersPayload = [];
diff --git a/apps/web/src/features/settings/hooks/use-mcp-servers.tsx b/apps/web/src/features/settings/hooks/use-mcp-servers.tsx
index 94ad4c75..9f8e4d53 100644
--- a/apps/web/src/features/settings/hooks/use-mcp-servers.tsx
+++ b/apps/web/src/features/settings/hooks/use-mcp-servers.tsx
@@ -8,6 +8,7 @@ import { useTenantContext } from "@/providers/Tenant";
export interface McpServer {
id: string;
name: string;
+ slug: string;
url: string;
authType: "none" | "bearer" | "apiKey";
credentials: string | null;
diff --git a/apps/web/src/lib/mcp-defaults.ts b/apps/web/src/lib/mcp-defaults.ts
index 4918e56d..2a428197 100644
--- a/apps/web/src/lib/mcp-defaults.ts
+++ b/apps/web/src/lib/mcp-defaults.ts
@@ -1,6 +1,7 @@
export interface McpServerDefault {
id: string;
name: string;
+ slug: string;
url: string;
authType: "bearer";
credentials: string | null;
@@ -17,6 +18,7 @@ export function getDefaultServers(): McpServerDefault[] {
defaults.push({
id: "default-typebot",
name: "Typebot",
+ slug: "typebot",
url: process.env.MCP_TYPEBOT_URL,
authType: "bearer",
credentials: process.env.MCP_TYPEBOT_BEARER_TOKEN ?? null,
@@ -31,6 +33,7 @@ export function getDefaultServers(): McpServerDefault[] {
defaults.push({
id: "default-cloudhumans",
name: "CloudHumans",
+ slug: "cloudhumans",
url: process.env.MCP_CLOUDHUMANS_URL,
authType: "bearer",
credentials: process.env.MCP_CLOUDHUMANS_BEARER_TOKEN ?? null,
diff --git a/apps/web/src/lib/mcp-slug.ts b/apps/web/src/lib/mcp-slug.ts
index 4bd71b77..9c793e3a 100644
--- a/apps/web/src/lib/mcp-slug.ts
+++ b/apps/web/src/lib/mcp-slug.ts
@@ -1,5 +1,5 @@
// MCP server slug utilities — must stay in sync with
-// claudia-agentic/src/packages/src/react_agent/react_agent.ts (toServerSlug, deduplicateSlugs)
+// claudia-agentic/src/packages/src/react_agent/mcp_tools.ts (toServerSlug)
export function toServerSlug(name: string): string {
return name
@@ -7,13 +7,3 @@ export function toServerSlug(name: string): string {
.replace(/[^a-z0-9]+/g, "_")
.replace(/^_+|_+$/g, "");
}
-
-export function deduplicateSlugs(slugs: string[]): string[] {
- const counts = new Map();
- return slugs.map((slug) => {
- const base = slug || "server";
- const count = (counts.get(base) ?? 0) + 1;
- counts.set(base, count);
- return count === 1 ? base : `${base}_${count}`;
- });
-}
diff --git a/apps/web/src/models/mcp-server.ts b/apps/web/src/models/mcp-server.ts
index d69c5322..61294624 100644
--- a/apps/web/src/models/mcp-server.ts
+++ b/apps/web/src/models/mcp-server.ts
@@ -2,6 +2,7 @@ import mongoose, { Schema, Document } from "mongoose";
export interface IMcpServer extends Document {
name: string;
+ slug: string;
url: string;
authType: "none" | "bearer" | "apiKey";
credentials: string | null;
@@ -14,6 +15,7 @@ export interface IMcpServer extends Document {
const McpServerSchema = new Schema(
{
name: { type: String, required: true },
+ slug: { type: String, required: true },
url: { type: String, required: true },
authType: {
type: String,
@@ -30,7 +32,7 @@ const McpServerSchema = new Schema(
},
);
-McpServerSchema.index({ tenantName: 1, name: 1 });
+McpServerSchema.index({ tenantName: 1, slug: 1 }, { unique: true });
const McpServer =
(mongoose.models.McpServer as mongoose.Model) ||
From 76f07b1e39bd5106227758af3d36d926cefa333a Mon Sep 17 00:00:00 2001
From: fjunqueira
Date: Fri, 27 Feb 2026 16:07:08 -0300
Subject: [PATCH 19/20] refactor: remove toServerSlug fallback from edit dialog
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Slug is always present in snapshot — no need for runtime fallback.
Co-Authored-By: Claude Opus 4.6
---
.../create-edit-agent-dialogs/edit-agent-dialog.tsx | 5 ++---
1 file changed, 2 insertions(+), 3 deletions(-)
diff --git a/apps/web/src/features/agents/components/create-edit-agent-dialogs/edit-agent-dialog.tsx b/apps/web/src/features/agents/components/create-edit-agent-dialogs/edit-agent-dialog.tsx
index 416c3427..517faccf 100644
--- a/apps/web/src/features/agents/components/create-edit-agent-dialogs/edit-agent-dialog.tsx
+++ b/apps/web/src/features/agents/components/create-edit-agent-dialogs/edit-agent-dialog.tsx
@@ -22,7 +22,7 @@ import { hasStaleSupervisors } from "@/lib/agent-utils";
import { StaleSupervisorsWarningDialog } from "./stale-supervisors-warning-dialog";
import { useMcpServers } from "@/features/settings/hooks/use-mcp-servers";
import { useAuthContext } from "@/providers/Auth";
-import { toServerSlug } from "@/lib/mcp-slug";
+
interface EditAgentDialogProps {
agent: Agent;
@@ -88,8 +88,7 @@ function EditAgentDialogContent({
if (existingSnapshot.length > 0) {
const toolsByServer: Record = {};
for (const snap of existingSnapshot) {
- // Use stored slug, fall back to computing from name for old configs
- const slug = snap.slug ?? toServerSlug(snap.name ?? "");
+ const slug = snap.slug!;
const prefix = `${slug}__`;
// Match by id first, fall back to name (for snapshots saved before id was added)
const server =
From 76735547249ed43157f9f490ae1f0bd9c74e6bb0 Mon Sep 17 00:00:00 2001
From: fjunqueira
Date: Thu, 26 Mar 2026 19:50:40 -0300
Subject: [PATCH 20/20] fix: server-side tenant validation, encrypted
credential passthrough, remove disabled servers
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Three fixes:
1. Tenant validation: requireAuth now extracts cognito:groups from
the JWT and validates x-tenant-name against it. @cloudhumans.com
emails bypass the check for full access.
2. Credentials: snapshot endpoint now returns encrypted credentials
(MongoDB values pass through as-is, default server values encrypted
on the fly). Credentials are never decrypted in the frontend flow.
3. Removed the disabled/enabled toggle from MCP servers — servers are
either present or deleted, eliminating ghost state in agent configs.
Co-Authored-By: Claude Opus 4.6 (1M context)
---
.../web/src/app/api/mcp-servers/[id]/route.ts | 4 ---
apps/web/src/app/api/mcp-servers/route.ts | 4 ---
.../src/app/api/mcp-servers/snapshot/route.ts | 9 +++----
.../edit-agent-dialog.tsx | 16 ++++++++++++
.../mcp-server-tool-groups.tsx | 2 +-
.../mcp-servers/mcp-server-list.tsx | 3 ---
.../components/mcp-servers/mcp-server-row.tsx | 10 +-------
.../settings/hooks/use-mcp-servers.tsx | 25 -------------------
apps/web/src/features/settings/index.tsx | 2 --
apps/web/src/lib/auth/cognito-server.ts | 11 +++++---
apps/web/src/lib/auth/require-auth.ts | 22 +++++++++++++---
apps/web/src/lib/mcp-defaults.ts | 3 ---
apps/web/src/models/mcp-server.ts | 2 --
13 files changed, 46 insertions(+), 67 deletions(-)
diff --git a/apps/web/src/app/api/mcp-servers/[id]/route.ts b/apps/web/src/app/api/mcp-servers/[id]/route.ts
index cfdd4383..4bf961d4 100644
--- a/apps/web/src/app/api/mcp-servers/[id]/route.ts
+++ b/apps/web/src/app/api/mcp-servers/[id]/route.ts
@@ -17,7 +17,6 @@ const UpdateMcpServerSchema = z
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)) {
@@ -98,8 +97,6 @@ export async function PUT(
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 =
@@ -129,7 +126,6 @@ export async function PUT(
updated.credentials != null
? maskCredential(decrypt(updated.credentials))
: null,
- enabled: updated.enabled,
isDefault: false,
createdAt: updated.createdAt,
updatedAt: updated.updatedAt,
diff --git a/apps/web/src/app/api/mcp-servers/route.ts b/apps/web/src/app/api/mcp-servers/route.ts
index 4b3e9c63..1818ca12 100644
--- a/apps/web/src/app/api/mcp-servers/route.ts
+++ b/apps/web/src/app/api/mcp-servers/route.ts
@@ -14,7 +14,6 @@ const CreateMcpServerSchema = z
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") {
@@ -53,7 +52,6 @@ export async function GET(req: NextRequest) {
url: string;
authType: "none" | "bearer" | "apiKey";
credentials: string | null;
- enabled: boolean;
isDefault: false;
createdAt: Date;
updatedAt: Date;
@@ -74,7 +72,6 @@ export async function GET(req: NextRequest) {
doc.credentials != null
? maskCredential(decrypt(doc.credentials))
: null,
- enabled: doc.enabled,
isDefault: false,
createdAt: doc.createdAt,
updatedAt: doc.updatedAt,
@@ -145,7 +142,6 @@ export async function POST(req: NextRequest) {
doc.credentials != null
? maskCredential(decrypt(doc.credentials))
: null,
- enabled: doc.enabled,
isDefault: false,
createdAt: doc.createdAt,
updatedAt: doc.updatedAt,
diff --git a/apps/web/src/app/api/mcp-servers/snapshot/route.ts b/apps/web/src/app/api/mcp-servers/snapshot/route.ts
index 8af45a09..f81b1a2e 100644
--- a/apps/web/src/app/api/mcp-servers/snapshot/route.ts
+++ b/apps/web/src/app/api/mcp-servers/snapshot/route.ts
@@ -1,7 +1,7 @@
import { NextRequest } from "next/server";
import mongoose from "mongoose";
import { connectDB } from "@/lib/mongodb";
-import { decrypt } from "@/lib/encryption";
+import { encrypt } from "@/lib/encryption";
import { getDefaultServers } from "@/lib/mcp-defaults";
import McpServer from "@/models/mcp-server";
import { requireAuth } from "@/lib/auth/require-auth";
@@ -54,7 +54,7 @@ export async function GET(req: NextRequest) {
slug: server.slug,
url: server.url,
authType: server.authType,
- credentials: server.credentials,
+ credentials: server.credentials ? encrypt(server.credentials) : null,
});
}
}
@@ -81,16 +81,13 @@ export async function GET(req: NextRequest) {
}).lean();
for (const doc of docs) {
- const decrypted =
- doc.credentials != null ? decrypt(doc.credentials) : null;
-
results.push({
id: doc._id.toString(),
name: doc.name,
slug: doc.slug,
url: doc.url,
authType: doc.authType,
- credentials: decrypted,
+ credentials: doc.credentials, // already encrypted in MongoDB
});
}
}
diff --git a/apps/web/src/features/agents/components/create-edit-agent-dialogs/edit-agent-dialog.tsx b/apps/web/src/features/agents/components/create-edit-agent-dialogs/edit-agent-dialog.tsx
index 517faccf..31c679c8 100644
--- a/apps/web/src/features/agents/components/create-edit-agent-dialogs/edit-agent-dialog.tsx
+++ b/apps/web/src/features/agents/components/create-edit-agent-dialogs/edit-agent-dialog.tsx
@@ -86,6 +86,7 @@ function EditAgentDialogContent({
const rawSnapshot = (agent.config?.configurable?.mcp_servers ?? []) as unknown;
const existingSnapshot: { id?: string; name?: string; slug?: string; tools?: string[] }[] = Array.isArray(rawSnapshot) ? rawSnapshot : [];
if (existingSnapshot.length > 0) {
+ // New format: mcp_servers array with slug-prefixed tool names
const toolsByServer: Record = {};
for (const snap of existingSnapshot) {
const slug = snap.slug!;
@@ -103,6 +104,19 @@ function EditAgentDialogContent({
if (Object.keys(toolsByServer).length > 0) {
setSelectedToolsByServer(toolsByServer);
}
+ } else if (!Array.isArray(rawSnapshot)) {
+ // Legacy format (mcp_servers absent, not empty array): single mcp_config with url + unprefixed tool names
+ const legacyConfig = agent.config?.configurable?.mcp_config as
+ | { url?: string; tools?: string[] }
+ | undefined;
+ if (legacyConfig?.url && Array.isArray(legacyConfig.tools) && legacyConfig.tools.length > 0) {
+ const normalizeUrl = (u: string) => (u.endsWith("/mcp") ? u : `${u}/mcp`);
+ const legacyUrl = normalizeUrl(legacyConfig.url);
+ const server = availableServers.find((s) => normalizeUrl(s.url) === legacyUrl);
+ if (server) {
+ setSelectedToolsByServer({ [server.id]: legacyConfig.tools });
+ }
+ }
}
}, [hasMcpServers, availableServers, agent.config?.configurable?.mcp_servers]);
@@ -173,6 +187,8 @@ function EditAgentDialogContent({
// Only include mcp_servers if the graph schema declares it
if (hasMcpServers) {
configPayload.mcp_servers = mcpServersPayload;
+ // Remove legacy mcp_config so the agent fully migrates to the new format
+ delete configPayload.mcp_config;
}
const updatedAgent = await updateAgent(
diff --git a/apps/web/src/features/agents/components/create-edit-agent-dialogs/mcp-server-selector/mcp-server-tool-groups.tsx b/apps/web/src/features/agents/components/create-edit-agent-dialogs/mcp-server-selector/mcp-server-tool-groups.tsx
index 34828eb2..44353c95 100644
--- a/apps/web/src/features/agents/components/create-edit-agent-dialogs/mcp-server-selector/mcp-server-tool-groups.tsx
+++ b/apps/web/src/features/agents/components/create-edit-agent-dialogs/mcp-server-selector/mcp-server-tool-groups.tsx
@@ -136,7 +136,7 @@ export function McpServerToolGroups({
searchTerm,
tenant,
}: McpServerToolGroupsProps) {
- const servers = allServers.filter((s) => s.enabled);
+ const servers = allServers;
const handleToolToggle = (
serverId: string,
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 37e8f098..646bd5b5 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
@@ -11,7 +11,6 @@ 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?: (body: Record) => Promise;
}
@@ -19,7 +18,6 @@ interface McpServerListProps {
export function McpServerList({
servers,
loading,
- onToggle,
renderActions,
onAdd,
}: McpServerListProps): React.ReactNode {
@@ -68,7 +66,6 @@ export function McpServerList({
))}
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
index 4a25bc84..91432d77 100644
--- 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
@@ -2,18 +2,15 @@
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 (
@@ -31,13 +28,8 @@ export function McpServerRow({
{server.url}
- {/* Right: toggle + actions slot */}
+ {/* Right: actions slot */}
- onToggle(server.id, checked)}
- disabled={server.isDefault}
- />
{actions}
diff --git a/apps/web/src/features/settings/hooks/use-mcp-servers.tsx b/apps/web/src/features/settings/hooks/use-mcp-servers.tsx
index 9f8e4d53..f6f3736e 100644
--- a/apps/web/src/features/settings/hooks/use-mcp-servers.tsx
+++ b/apps/web/src/features/settings/hooks/use-mcp-servers.tsx
@@ -12,7 +12,6 @@ export interface McpServer {
url: string;
authType: "none" | "bearer" | "apiKey";
credentials: string | null;
- enabled: boolean;
isDefault: boolean;
createdAt: string | null;
updatedAt: string | null;
@@ -25,7 +24,6 @@ interface UseMcpServersReturn {
addServer: (body: Omit) => Promise;
updateServer: (id: string, body: Partial>) => Promise;
deleteServer: (id: string) => Promise;
- toggleServer: (id: string, enabled: boolean) => Promise;
refetch: () => Promise;
}
@@ -124,28 +122,6 @@ export function useMcpServers(): UseMcpServersReturn {
}
}, [getAuthHeaders]);
- 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: getAuthHeaders(),
- 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");
- }
- }, [getAuthHeaders]);
-
return {
servers,
loading,
@@ -153,7 +129,6 @@ export function useMcpServers(): UseMcpServersReturn {
addServer,
updateServer,
deleteServer,
- toggleServer,
refetch: fetchServers,
};
}
diff --git a/apps/web/src/features/settings/index.tsx b/apps/web/src/features/settings/index.tsx
index 374b94c0..89eac5bb 100644
--- a/apps/web/src/features/settings/index.tsx
+++ b/apps/web/src/features/settings/index.tsx
@@ -17,7 +17,6 @@ export default function SettingsInterface(): React.ReactNode {
const {
servers,
loading: mcpLoading,
- toggleServer,
addServer,
updateServer,
deleteServer,
@@ -107,7 +106,6 @@ export default function SettingsInterface(): React.ReactNode {
!server.isDefault ? (
diff --git a/apps/web/src/lib/auth/cognito-server.ts b/apps/web/src/lib/auth/cognito-server.ts
index 7185e08c..fbd19cfd 100644
--- a/apps/web/src/lib/auth/cognito-server.ts
+++ b/apps/web/src/lib/auth/cognito-server.ts
@@ -1,4 +1,5 @@
import { CognitoJwtVerifier } from "aws-jwt-verify";
+import { CognitoAccessTokenPayload } from "aws-jwt-verify/jwt-model";
let verifier: ReturnType | null = null;
@@ -25,12 +26,14 @@ function getVerifier() {
/**
* Verify a Cognito access token against the JWKS endpoint.
* Validates issuer, signature, expiration, token_use, and client_id.
+ * Returns the decoded payload on success, or null on failure.
*/
-export async function verifyCognitoToken(token: string): Promise {
+export async function verifyCognitoToken(
+ token: string,
+): Promise {
try {
- await getVerifier().verify(token);
- return true;
+ return await getVerifier().verify(token);
} catch {
- return false;
+ return null;
}
}
diff --git a/apps/web/src/lib/auth/require-auth.ts b/apps/web/src/lib/auth/require-auth.ts
index b3b7e219..c4a59650 100644
--- a/apps/web/src/lib/auth/require-auth.ts
+++ b/apps/web/src/lib/auth/require-auth.ts
@@ -2,7 +2,7 @@ import { NextRequest } from "next/server";
import { verifyCognitoToken } from "./cognito-server";
type AuthResult =
- | { ok: true; tenantName: string }
+ | { ok: true; tenantName: string; groups: string[] }
| { ok: false; response: Response };
/**
@@ -22,8 +22,8 @@ export async function requireAuth(req: NextRequest): Promise {
}
const token = authHeader.slice(7);
- const valid = await verifyCognitoToken(token);
- if (!valid) {
+ const payload = await verifyCognitoToken(token);
+ if (!payload) {
return {
ok: false,
response: Response.json(
@@ -44,5 +44,19 @@ export async function requireAuth(req: NextRequest): Promise {
};
}
- return { ok: true, tenantName };
+ const groups: string[] = (payload as any)["cognito:groups"] ?? [];
+ const username: string = (payload as any).username ?? "";
+ const isCloudHumans = username.endsWith("@cloudhumans.com");
+
+ if (!isCloudHumans && !groups.includes(tenantName)) {
+ return {
+ ok: false,
+ response: Response.json(
+ { error: "Forbidden", message: "User does not belong to this tenant" },
+ { status: 403 },
+ ),
+ };
+ }
+
+ return { ok: true, tenantName, groups };
}
diff --git a/apps/web/src/lib/mcp-defaults.ts b/apps/web/src/lib/mcp-defaults.ts
index 2a428197..a1750693 100644
--- a/apps/web/src/lib/mcp-defaults.ts
+++ b/apps/web/src/lib/mcp-defaults.ts
@@ -5,7 +5,6 @@ export interface McpServerDefault {
url: string;
authType: "bearer";
credentials: string | null;
- enabled: boolean;
isDefault: true;
createdAt: null;
updatedAt: null;
@@ -22,7 +21,6 @@ export function getDefaultServers(): McpServerDefault[] {
url: process.env.MCP_TYPEBOT_URL,
authType: "bearer",
credentials: process.env.MCP_TYPEBOT_BEARER_TOKEN ?? null,
- enabled: true,
isDefault: true,
createdAt: null,
updatedAt: null,
@@ -37,7 +35,6 @@ export function getDefaultServers(): McpServerDefault[] {
url: process.env.MCP_CLOUDHUMANS_URL,
authType: "bearer",
credentials: process.env.MCP_CLOUDHUMANS_BEARER_TOKEN ?? null,
- enabled: true,
isDefault: true,
createdAt: null,
updatedAt: null,
diff --git a/apps/web/src/models/mcp-server.ts b/apps/web/src/models/mcp-server.ts
index 61294624..43e69699 100644
--- a/apps/web/src/models/mcp-server.ts
+++ b/apps/web/src/models/mcp-server.ts
@@ -6,7 +6,6 @@ export interface IMcpServer extends Document {
url: string;
authType: "none" | "bearer" | "apiKey";
credentials: string | null;
- enabled: boolean;
tenantName: string;
createdAt: Date;
updatedAt: Date;
@@ -23,7 +22,6 @@ const McpServerSchema = new Schema(
required: true,
},
credentials: { type: String, default: null },
- enabled: { type: Boolean, default: true },
tenantName: { type: String, required: true },
},
{