diff --git a/apps/docs/quickstart.mdx b/apps/docs/quickstart.mdx
index 18756f104..183c69779 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 fc812adb7..03124206b 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 b07c28b1f..8a7bd43fa 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 1f0671be1..6b5c4f896 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_..."
@@ -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 2c629dac4..9ba020a3a 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",
@@ -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",
@@ -51,6 +50,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/app/(app)/layout.tsx b/apps/web/src/app/(app)/layout.tsx
index d7d1bb30b..f796304b5 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}
+
+
+ >
);
}
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 000000000..4bf961d47
--- /dev/null
+++ b/apps/web/src/app/api/mcp-servers/[id]/route.ts
@@ -0,0 +1,197 @@
+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";
+import { requireAuth } from "@/lib/auth/require-auth";
+import { toServerSlug } from "@/lib/mcp-slug";
+
+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(),
+ })
+ .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 }> },
+) {
+ const auth = await requireAuth(req);
+ if (!auth.ok) return auth.response;
+
+ 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;
+ 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;
+
+ if (parsed.data.credentials !== undefined) {
+ updateData.credentials =
+ parsed.data.credentials != null
+ ? encrypt(parsed.data.credentials)
+ : null;
+ }
+
+ 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 });
+ }
+
+ return Response.json(
+ {
+ id: (updated._id as mongoose.Types.ObjectId).toString(),
+ name: updated.name,
+ slug: updated.slug,
+ url: updated.url,
+ authType: updated.authType,
+ credentials:
+ updated.credentials != null
+ ? maskCredential(decrypt(updated.credentials))
+ : null,
+ isDefault: false,
+ createdAt: updated.createdAt,
+ updatedAt: updated.updatedAt,
+ },
+ { status: 200 },
+ );
+ } 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" },
+ { status: 500 },
+ );
+ }
+}
+
+export async function DELETE(
+ req: NextRequest,
+ { params }: { params: Promise<{ id: string }> },
+) {
+ const auth = await requireAuth(req);
+ if (!auth.ok) return auth.response;
+
+ 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.findOneAndDelete({
+ _id: id,
+ tenantName: auth.tenantName,
+ }).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 },
+ );
+ }
+}
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 000000000..74394ace6
--- /dev/null
+++ b/apps/web/src/app/api/mcp-servers/[id]/tools/route.ts
@@ -0,0 +1,112 @@
+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";
+import { requireAuth } from "@/lib/auth/require-auth";
+
+export const runtime = "nodejs";
+export const dynamic = "force-dynamic";
+
+export async function GET(
+ 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;
+ 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.findOne({
+ _id: id,
+ tenantName: auth.tenantName,
+ }).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;
+ }
+
+ // 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, cache: "no-store" as RequestCache },
+ });
+
+ 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: 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/app/api/mcp-servers/route.ts b/apps/web/src/app/api/mcp-servers/route.ts
new file mode 100644
index 000000000..1818ca12f
--- /dev/null
+++ b/apps/web/src/app/api/mcp-servers/route.ts
@@ -0,0 +1,164 @@
+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";
+import { requireAuth } from "@/lib/auth/require-auth";
+import { toServerSlug } from "@/lib/mcp-slug";
+
+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(),
+ })
+ .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(req: NextRequest) {
+ const auth = await requireAuth(req);
+ if (!auth.ok) return auth.response;
+
+ const defaults = getDefaultServers().map((server) => ({
+ ...server,
+ credentials: maskCredential(server.credentials),
+ }));
+
+ const userServers: Array<{
+ id: string;
+ name: string;
+ slug: string;
+ url: string;
+ authType: "none" | "bearer" | "apiKey";
+ credentials: string | null;
+ isDefault: false;
+ createdAt: Date;
+ updatedAt: Date;
+ }> = [];
+
+ try {
+ await connectDB();
+ const docs = await McpServer.find({ tenantName: auth.tenantName }).lean();
+
+ for (const doc of docs) {
+ userServers.push({
+ id: (doc._id as mongoose.Types.ObjectId).toString(),
+ name: doc.name,
+ slug: doc.slug,
+ url: doc.url,
+ authType: doc.authType,
+ credentials:
+ doc.credentials != null
+ ? maskCredential(decrypt(doc.credentials))
+ : null,
+ 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) {
+ const auth = await requireAuth(req);
+ if (!auth.ok) return auth.response;
+
+ 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 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,
+ });
+
+ return Response.json(
+ {
+ id: doc._id.toString(),
+ name: doc.name,
+ slug: doc.slug,
+ url: doc.url,
+ authType: doc.authType,
+ credentials:
+ doc.credentials != null
+ ? maskCredential(decrypt(doc.credentials))
+ : null,
+ isDefault: false,
+ createdAt: doc.createdAt,
+ updatedAt: doc.updatedAt,
+ },
+ { status: 201 },
+ );
+ } 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" },
+ { status: 500 },
+ );
+ }
+}
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 000000000..f81b1a2e5
--- /dev/null
+++ b/apps/web/src/app/api/mcp-servers/snapshot/route.ts
@@ -0,0 +1,104 @@
+import { NextRequest } from "next/server";
+import mongoose from "mongoose";
+import { connectDB } from "@/lib/mongodb";
+import { encrypt } 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";
+
+interface ServerSnapshot {
+ id: string;
+ name: string;
+ slug: string;
+ url: string;
+ authType: "none" | "bearer" | "apiKey";
+ credentials: string | null;
+}
+
+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[]");
+
+ 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({
+ id: server.id,
+ name: server.name,
+ slug: server.slug,
+ url: server.url,
+ authType: server.authType,
+ credentials: server.credentials ? encrypt(server.credentials) : null,
+ });
+ }
+ }
+
+ // 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 },
+ tenantName: auth.tenantName,
+ }).lean();
+
+ for (const doc of docs) {
+ results.push({
+ id: doc._id.toString(),
+ name: doc.name,
+ slug: doc.slug,
+ url: doc.url,
+ authType: doc.authType,
+ credentials: doc.credentials, // already encrypted in MongoDB
+ });
+ }
+ }
+ }
+
+ 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 },
+ );
+ }
+}
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 254bbe05c..016af8cd7 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,8 @@ import {
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 (
@@ -46,6 +48,12 @@ interface AgentFieldsFormProps {
agentId: string;
ragConfigurations: ConfigurableFieldRAGMetadata[];
agentsConfigurations: ConfigurableFieldAgentsMetadata[];
+ hasMcpServers?: boolean;
+ mcpServers?: McpServer[];
+ mcpServersLoading?: boolean;
+ selectedToolsByServer?: Record;
+ onMcpToolSelectionChange?: (selection: Record) => void;
+ tenant?: string;
}
export function AgentFieldsForm({
@@ -54,6 +62,12 @@ export function AgentFieldsForm({
agentId,
ragConfigurations,
agentsConfigurations,
+ hasMcpServers = false,
+ mcpServers = [],
+ mcpServersLoading = false,
+ selectedToolsByServer = {},
+ onMcpToolSelectionChange,
+ tenant,
}: AgentFieldsFormProps) {
const form = useFormContext<{
name: string;
@@ -190,7 +204,7 @@ export function AgentFieldsForm({
>
)}
- {toolConfigurations.length > 0 && (
+ {(toolConfigurations.length > 0 || hasMcpServers) && (
<>
@@ -204,62 +218,76 @@ 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 && (
+
+ {
+ try {
+ setLoadingMore(true);
+ const moreTool = await getTools(cursor);
+ setTools((prevTools) => [
+ ...prevTools,
+ ...moreTool,
+ ]);
+ } catch (error) {
+ console.error("Failed to load more tools:", error);
+ } finally {
+ setLoadingMore(false);
+ }
+ }}
+ disabled={loadingMore || loading}
+ >
+ {loadingMore ? "Loading..." : "Load More Tools"}
+
+
+ )}
+ >
)}
- {tools.length === 0 && !toolSearchTerm && (
-
- No tools available for this agent.
-
- )}
- {cursor && !toolSearchTerm && (
-
- {
- try {
- setLoadingMore(true);
- const moreTool = await getTools(cursor);
- setTools((prevTools) => [
- ...prevTools,
- ...moreTool,
- ]);
- } catch (error) {
- console.error("Failed to load more tools:", error);
- } finally {
- setLoadingMore(false);
- }
- }}
- disabled={loadingMore || loading}
- >
- {loadingMore ? "Loading..." : "Load More Tools"}
-
-
+ {hasMcpServers && onMcpToolSelectionChange && (
+
)}
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 ae945234a..34fca3e83 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,8 @@ 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";
+import { useAuthContext } from "@/providers/Auth";
interface CreateAgentDialogProps {
agentId?: string;
@@ -35,6 +37,24 @@ function CreateAgentFormContent(props: {
selectedDeployment: Deployment;
onClose: () => void;
}) {
+ const { createAgent } = useAgents();
+ const { refreshAgents } = useAgentsContext();
+ const {
+ getSchemaAndUpdateConfig,
+ loading,
+ configurations,
+ toolConfigurations,
+ ragConfigurations,
+ agentsConfigurations,
+ hasMcpServers,
+ } = useAgentConfig();
+ const { selectedTenant, selectedTenantId } = useTenantContext();
+ const { session } = useAuthContext();
+ const { servers: availableServers, loading: serversLoading } = useMcpServers();
+ const [submitting, setSubmitting] = useState(false);
+ // New agents start with no MCP tools selected
+ const [selectedToolsByServer, setSelectedToolsByServer] = useState
>({});
+
const form = useForm<{
name: string;
description: string;
@@ -50,19 +70,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 +83,66 @@ function CreateAgentFormContent(props: {
return;
}
+ let mcpServersPayload: unknown[] | undefined;
+
+ if (hasMcpServers) {
+ // 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 = serverIdsWithTools.map((id) => `ids[]=${encodeURIComponent(id)}`).join("&");
+ 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",
+ richColors: true,
+ });
+ return;
+ }
+ const snapshotData = await snapshotRes.json();
+ // 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; 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] ?? []) : []).map(
+ (t) => `${slug}__${t}`,
+ ),
+ };
+ });
+ } 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 +150,7 @@ function CreateAgentFormContent(props: {
{
name,
description,
- config: {
- ...config,
- tenant: selectedTenant?.tenantName,
- },
+ config: configPayload,
},
);
setSubmitting(false);
@@ -120,6 +184,12 @@ function CreateAgentFormContent(props: {
toolConfigurations={toolConfigurations}
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 f27c55645..31c679c8d 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,9 @@ 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";
+import { useAuthContext } from "@/providers/Auth";
+
interface EditAgentDialogProps {
agent: Agent;
@@ -46,9 +49,15 @@ function EditAgentDialogContent({
toolConfigurations,
ragConfigurations,
agentsConfigurations,
+ hasMcpServers,
} = useAgentConfig();
- const { selectedTenant } = useTenantContext();
+ const { selectedTenant, selectedTenantId } = useTenantContext();
+ const { session } = useAuthContext();
const [deleteSubmitting, setDeleteSubmitting] = useState(false);
+ const [selectedToolsByServer, setSelectedToolsByServer] = useState>({});
+
+ // For pre-populating selected servers on edit: match existing snapshot names to current server IDs
+ const { servers: availableServers, loading: serversLoading } = useMcpServers();
const form = useForm<{
name: string;
@@ -63,6 +72,54 @@ function EditAgentDialogContent({
},
});
+ // 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);
+ 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: { 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!;
+ 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.map((t) =>
+ t.startsWith(prefix) ? t.slice(prefix.length) : t
+ );
+ }
+ }
+ 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]);
+
const handleSubmit = async (data: {
name: string;
description: string;
@@ -73,15 +130,73 @@ function EditAgentDialogContent({
return;
}
+ let mcpServersPayload: unknown[] | undefined;
+
+ if (hasMcpServers) {
+ // 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 = serverIdsWithTools.map((id) => `ids[]=${encodeURIComponent(id)}`).join("&");
+ 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",
+ });
+ return;
+ }
+ const snapshotData = await snapshotRes.json();
+ // 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; 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] ?? []) : []).map(
+ (t) => `${slug}__${t}`,
+ ),
+ };
+ });
+ } 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;
+ // Remove legacy mcp_config so the agent fully migrates to the new format
+ delete configPayload.mcp_config;
+ }
+
const updatedAgent = await updateAgent(
agent.assistant_id,
agent.deploymentId,
{
...data,
- config: {
- ...data.config,
- tenant: selectedTenant?.tenantName,
- },
+ config: configPayload,
},
);
@@ -148,6 +263,12 @@ function EditAgentDialogContent({
agentId={agent.assistant_id}
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
new file mode 100644
index 000000000..44353c95d
--- /dev/null
+++ b/apps/web/src/features/agents/components/create-edit-agent-dialogs/mcp-server-selector/mcp-server-tool-groups.tsx
@@ -0,0 +1,246 @@
+"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 { 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 (
+
+
+
+ {_.startCase(tool.name)}
+
+
+ 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 {
+ servers: McpServer[];
+ serversLoading: boolean;
+ selectedToolsByServer: Record;
+ onSelectionChange: (selection: Record) => void;
+ searchTerm?: string;
+ tenant?: string;
+}
+
+export function McpServerToolGroups({
+ servers: allServers,
+ serversLoading: loading,
+ selectedToolsByServer,
+ onSelectionChange,
+ searchTerm,
+ tenant,
+}: McpServerToolGroupsProps) {
+ const servers = allServers;
+
+ 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
new file mode 100644
index 000000000..2baaac418
--- /dev/null
+++ b/apps/web/src/features/agents/components/create-edit-agent-dialogs/mcp-server-selector/use-mcp-server-tools.tsx
@@ -0,0 +1,106 @@
+"use client";
+
+import { useState, useEffect, useCallback } from "react";
+import { useAuthContext } from "@/providers/Auth";
+import { useTenantContext } from "@/providers/Tenant";
+
+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,
+ tenant?: string,
+): UseMcpServerToolsReturn {
+ const { session } = useAuthContext();
+ const { selectedTenantId } = useTenantContext();
+ 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 url = tenant
+ ? `/api/mcp-servers/${serverId}/tools?tenant=${encodeURIComponent(tenant)}`
+ : `/api/mcp-servers/${serverId}/tools`;
+
+ 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) {
+ 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, tenant, fetchCounter, session?.accessToken, selectedTenantId]);
+
+ return { tools, loading, error, refetch };
+}
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 000000000..c36d9dc8d
--- /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 000000000..d0959ab05
--- /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={
+
+
+ Edit
+
+ }
+ />
+ onDelete(server.id)}
+ trigger={
+
+
+ Delete
+
+ }
+ />
+
+
+
+ );
+}
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 000000000..1f806fd20
--- /dev/null
+++ b/apps/web/src/features/settings/components/mcp-servers/mcp-server-form-dialog.tsx
@@ -0,0 +1,192 @@
+"use client";
+
+import React, { useState, useEffect } from "react";
+import { Button } from "@/components/ui/button";
+import {
+ Dialog,
+ DialogContent,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import type { McpServer } from "../../hooks/use-mcp-servers";
+
+interface McpServerFormDialogProps {
+ server?: McpServer;
+ onSave: (body: Record) => Promise;
+ trigger: React.ReactNode;
+}
+
+export function McpServerFormDialog({
+ server,
+ onSave,
+ trigger,
+}: McpServerFormDialogProps): React.ReactNode {
+ const [open, setOpen] = useState(false);
+ const [name, setName] = useState("");
+ const [url, setUrl] = useState("");
+ const [authType, setAuthType] = useState<"none" | "bearer" | "apiKey">(
+ "none",
+ );
+ const [credentials, setCredentials] = useState("");
+ const [saving, setSaving] = useState(false);
+
+ // Reset form state when dialog opens
+ useEffect(() => {
+ if (open) {
+ setName(server?.name ?? "");
+ setUrl(server?.url ?? "");
+ setAuthType(server?.authType ?? "none");
+ setCredentials(""); // CRITICAL: never pre-fill with masked value
+ }
+ }, [open, server]);
+
+ const isEditMode = Boolean(server);
+
+ const isValid =
+ name.trim().length > 0 &&
+ url.trim().length > 0 &&
+ (authType === "none" ||
+ isEditMode || // in edit mode, empty credentials means "don't change"
+ credentials.trim().length > 0);
+
+ function handleAuthTypeChange(value: string) {
+ const newType = value as "none" | "bearer" | "apiKey";
+ setAuthType(newType);
+ if (newType === "none") {
+ setCredentials(""); // Pitfall 5: clear stale credentials
+ }
+ }
+
+ async function handleSubmit() {
+ const body: Record = {
+ name: name.trim(),
+ url: url.trim(),
+ authType,
+ };
+
+ if (authType === "none") {
+ body.credentials = null;
+ } else if (credentials !== "") {
+ // Pitfall 1: only include credentials if user actually typed something
+ body.credentials = credentials;
+ }
+ // In edit mode with empty credentials: omit credentials entirely
+
+ setSaving(true);
+ try {
+ await onSave(body);
+ setOpen(false);
+ } catch {
+ // Keep dialog open on error
+ } finally {
+ setSaving(false);
+ }
+ }
+
+ return (
+
+ {/* Render trigger as-is wrapped in a span to avoid nesting issues */}
+ setOpen(true)}
+ onKeyDown={(e) => e.key === "Enter" && setOpen(true)}
+ style={{ display: "contents" }}
+ >
+ {trigger}
+
+
+
+
+ {isEditMode ? "Edit MCP Server" : "Add MCP Server"}
+
+
+
+
+ {/* Name field */}
+
+ Name
+ setName(e.target.value)}
+ placeholder="My Server"
+ />
+
+
+ {/* URL field */}
+
+ URL
+ setUrl(e.target.value)}
+ placeholder="https://mcp.example.com/sse"
+ />
+
+
+ {/* Auth Type field */}
+
+ Auth Type
+
+
+
+
+
+ None
+ Bearer Token
+ API Key
+
+
+
+
+ {/* Credentials field (conditional) */}
+ {authType !== "none" && (
+
+
+ {authType === "bearer" ? "Bearer Token" : "API Key"}
+ {isEditMode && (
+
+ (re-enter to change)
+
+ )}
+
+ setCredentials(e.target.value)}
+ placeholder={server?.credentials ?? ""}
+ />
+
+ )}
+
+
+
+
+ {isEditMode ? "Save Changes" : "Add Server"}
+
+
+
+
+ );
+}
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 000000000..646bd5b59
--- /dev/null
+++ b/apps/web/src/features/settings/components/mcp-servers/mcp-server-list.tsx
@@ -0,0 +1,76 @@
+"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 {
+ servers: McpServer[];
+ loading: boolean;
+ renderActions?: (server: McpServer) => React.ReactNode;
+ onAdd?: (body: Record) => Promise;
+}
+
+export function McpServerList({
+ servers,
+ loading,
+ renderActions,
+ onAdd,
+}: McpServerListProps): React.ReactNode {
+ return (
+
+ {/* Top bar */}
+
+
+ Manage MCP server connections for your agents.
+
+ {onAdd && (
+
+
+ Add Server
+
+ }
+ />
+ )}
+
+
+ {/* 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 000000000..91432d772
--- /dev/null
+++ b/apps/web/src/features/settings/components/mcp-servers/mcp-server-row.tsx
@@ -0,0 +1,37 @@
+"use client";
+
+import React from "react";
+import { Badge } from "@/components/ui/badge";
+import type { McpServer } from "../../hooks/use-mcp-servers";
+
+interface McpServerRowProps {
+ server: McpServer;
+ actions?: React.ReactNode;
+}
+
+export function McpServerRow({
+ server,
+ actions,
+}: McpServerRowProps): React.ReactNode {
+ return (
+
+ {/* Left: name + badge + URL */}
+
+
+ {server.name}
+ {server.isDefault && (
+
+ Default
+
+ )}
+
+
{server.url}
+
+
+ {/* Right: actions slot */}
+
+ {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
new file mode 100644
index 000000000..f6f3736ee
--- /dev/null
+++ b/apps/web/src/features/settings/hooks/use-mcp-servers.tsx
@@ -0,0 +1,134 @@
+"use client";
+
+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;
+ name: string;
+ slug: string;
+ url: string;
+ authType: "none" | "bearer" | "apiKey";
+ credentials: string | null;
+ 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;
+ refetch: () => Promise;
+}
+
+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", { headers: getAuthHeaders() });
+ 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);
+ }
+ }, [getAuthHeaders, selectedTenantId]);
+
+ useEffect(() => {
+ fetchServers();
+ }, [fetchServers]);
+
+ const addServer = useCallback(
+ async (body: Omit) => {
+ try {
+ const res = await fetch("/api/mcp-servers", {
+ method: "POST",
+ headers: getAuthHeaders(),
+ 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");
+ }
+ },
+ [getAuthHeaders],
+ );
+
+ const updateServer = useCallback(
+ async (id: string, body: Partial>) => {
+ try {
+ const res = await fetch(`/api/mcp-servers/${id}`, {
+ method: "PUT",
+ headers: getAuthHeaders(),
+ 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");
+ }
+ },
+ [getAuthHeaders],
+ );
+
+ const deleteServer = useCallback(async (id: string) => {
+ try {
+ 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");
+ } catch (err) {
+ console.error("[useMcpServers] Failed to delete:", err);
+ toast.error("Failed to delete server");
+ }
+ }, [getAuthHeaders]);
+
+ return {
+ servers,
+ loading,
+ error,
+ addServer,
+ updateServer,
+ deleteServer,
+ refetch: fetchServers,
+ };
+}
diff --git a/apps/web/src/features/settings/index.tsx b/apps/web/src/features/settings/index.tsx
index b9ba8a5c2..89eac5bb4 100644
--- a/apps/web/src/features/settings/index.tsx
+++ b/apps/web/src/features/settings/index.tsx
@@ -6,11 +6,22 @@ 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";
+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,
+ addServer,
+ updateServer,
+ deleteServer,
+ } = useMcpServers();
+
// Use localStorage hooks for each API key
const [openaiApiKey, setOpenaiApiKey] = useLocalStorage(
"lg:settings:openaiApiKey",
@@ -88,6 +99,25 @@ export default function SettingsInterface(): React.ReactNode {
+
+
+
+
MCP Servers
+
+ !server.isDefault ? (
+
+ ) : null
+ }
+ />
+
);
}
diff --git a/apps/web/src/hooks/use-agent-config.tsx b/apps/web/src/hooks/use-agent-config.tsx
index 9bcd0637f..9649890ec 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,
};
diff --git a/apps/web/src/lib/auth/cognito-server.ts b/apps/web/src/lib/auth/cognito-server.ts
index 7185e08c8..fbd19cfda 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
new file mode 100644
index 000000000..c4a596504
--- /dev/null
+++ b/apps/web/src/lib/auth/require-auth.ts
@@ -0,0 +1,62 @@
+import { NextRequest } from "next/server";
+import { verifyCognitoToken } from "./cognito-server";
+
+type AuthResult =
+ | { ok: true; tenantName: string; groups: 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 payload = await verifyCognitoToken(token);
+ if (!payload) {
+ 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 },
+ ),
+ };
+ }
+
+ 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/encryption.ts b/apps/web/src/lib/encryption.ts
new file mode 100644
index 000000000..6fda79d8d
--- /dev/null
+++ b/apps/web/src/lib/encryption.ts
@@ -0,0 +1,84 @@
+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) {
+ 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.",
+ );
+ 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/mcp-defaults.ts b/apps/web/src/lib/mcp-defaults.ts
new file mode 100644
index 000000000..a17506937
--- /dev/null
+++ b/apps/web/src/lib/mcp-defaults.ts
@@ -0,0 +1,45 @@
+export interface McpServerDefault {
+ id: string;
+ name: string;
+ slug: string;
+ url: string;
+ authType: "bearer";
+ credentials: string | null;
+ 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",
+ slug: "typebot",
+ url: process.env.MCP_TYPEBOT_URL,
+ authType: "bearer",
+ credentials: process.env.MCP_TYPEBOT_BEARER_TOKEN ?? null,
+ isDefault: true,
+ createdAt: null,
+ updatedAt: null,
+ });
+ }
+
+ if (process.env.MCP_CLOUDHUMANS_URL) {
+ 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,
+ isDefault: true,
+ createdAt: null,
+ updatedAt: null,
+ });
+ }
+
+ return defaults;
+}
diff --git a/apps/web/src/lib/mcp-slug.ts b/apps/web/src/lib/mcp-slug.ts
new file mode 100644
index 000000000..9c793e3a6
--- /dev/null
+++ b/apps/web/src/lib/mcp-slug.ts
@@ -0,0 +1,9 @@
+// MCP server slug utilities — must stay in sync with
+// claudia-agentic/src/packages/src/react_agent/mcp_tools.ts (toServerSlug)
+
+export function toServerSlug(name: string): string {
+ return name
+ .toLowerCase()
+ .replace(/[^a-z0-9]+/g, "_")
+ .replace(/^_+|_+$/g, "");
+}
diff --git a/apps/web/src/lib/mongodb.ts b/apps/web/src/lib/mongodb.ts
new file mode 100644
index 000000000..445f27aee
--- /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/apps/web/src/models/mcp-server.ts b/apps/web/src/models/mcp-server.ts
new file mode 100644
index 000000000..43e696991
--- /dev/null
+++ b/apps/web/src/models/mcp-server.ts
@@ -0,0 +1,39 @@
+import mongoose, { Schema, Document } from "mongoose";
+
+export interface IMcpServer extends Document {
+ name: string;
+ slug: string;
+ url: string;
+ authType: "none" | "bearer" | "apiKey";
+ credentials: string | null;
+ tenantName: string;
+ createdAt: Date;
+ updatedAt: Date;
+}
+
+const McpServerSchema = new Schema(
+ {
+ name: { type: String, required: true },
+ slug: { type: String, required: true },
+ url: { type: String, required: true },
+ authType: {
+ type: String,
+ enum: ["none", "bearer", "apiKey"],
+ required: true,
+ },
+ credentials: { type: String, default: null },
+ tenantName: { type: String, required: true },
+ },
+ {
+ timestamps: true,
+ collection: "mcp",
+ },
+);
+
+McpServerSchema.index({ tenantName: 1, slug: 1 }, { unique: true });
+
+const McpServer =
+ (mongoose.models.McpServer as mongoose.Model) ||
+ mongoose.model("McpServer", McpServerSchema);
+
+export default McpServer;
diff --git a/yarn.lock b/yarn.lock
index 629f2939b..8d66b28f6 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -1623,6 +1623,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"
@@ -1837,6 +1846,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
@@ -4269,6 +4279,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"
@@ -5388,6 +5414,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"
@@ -9257,6 +9290,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.33
resolution: "katex@npm:0.16.33"
@@ -9969,6 +10009,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"
@@ -10706,6 +10753,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.34.3":
version: 12.34.3
resolution: "motion-dom@npm:12.34.3"
@@ -10722,6 +10827,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"
@@ -11848,7 +11967,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
@@ -13141,6 +13260,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"
@@ -13308,6 +13434,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"
@@ -13797,6 +13932,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"
@@ -14646,6 +14790,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"