Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
8bb26ec
feat(01-01): install mongoose and create data layer foundation
fjunqueira Feb 24, 2026
615b66a
feat(01-01): create Mongoose model and default server assembly
fjunqueira Feb 24, 2026
16e8cc3
feat(01-02): add GET and POST handlers for /api/mcp-servers
fjunqueira Feb 24, 2026
356e205
feat(01-02): add PUT and DELETE handlers for /api/mcp-servers/[id]
fjunqueira Feb 24, 2026
9d42768
feat(03-01): create useMcpServers CRUD hook
fjunqueira Feb 25, 2026
a35f452
feat(03-01): add MCP Servers list UI and integrate into Settings page
fjunqueira Feb 25, 2026
2981c24
feat(03-02): create McpServerFormDialog for Add and Edit modes
fjunqueira Feb 25, 2026
72acfd5
feat(03-02): wire full CRUD UI — DeleteServerAlert, McpServerActions,…
fjunqueira Feb 25, 2026
cfed419
fix: remove nested html/body from (app) layout to fix hydration error
fjunqueira Feb 25, 2026
c02b055
feat(04-01): add MCP tool proxy and snapshot API routes
fjunqueira Feb 25, 2026
d81d8d6
feat(04-01): add Checkbox UI component
fjunqueira Feb 25, 2026
d2263f4
feat(04-02): create useMcpServerTools hook and McpServerSelector comp…
fjunqueira Feb 25, 2026
7a832b0
feat(04-02): integrate McpServerSelector into agent form with snapsho…
fjunqueira Feb 25, 2026
efb4dad
:sparkles: add multi mcp support
fjunqueira Feb 25, 2026
10d2f56
Merge branch 'main' into feat/multi-mcp
fjunqueira Feb 25, 2026
da1b20a
Merge remote-tracking branch 'origin/main' into feat/multi-mcp
fjunqueira Feb 27, 2026
0ae7ec7
feat(mcp): add Cognito auth + tenant scoping to MCP server endpoints
fjunqueira Feb 27, 2026
75e58b1
fix(mcp): ID-based matching, disabled filter, dedup hooks, prod guard…
fjunqueira Feb 27, 2026
baae220
feat: store MCP tool names as slug-prefixed in agent config
fjunqueira Feb 27, 2026
dc7a03b
refactor: add slug field to MCP servers, remove deduplicateSlugs
fjunqueira Feb 27, 2026
76f07b1
refactor: remove toServerSlug fallback from edit dialog
fjunqueira Feb 27, 2026
ae7deff
Merge remote-tracking branch 'origin/main' into feat/multi-mcp
fjunqueira Mar 26, 2026
7673554
fix: server-side tenant validation, encrypted credential passthrough,…
fjunqueira Mar 26, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions apps/docs/quickstart.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ description: 'Follow these steps to get your Open Agent Platform up and running
</Tip>
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="..."
Expand Down Expand Up @@ -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!
2 changes: 1 addition & 1 deletion apps/docs/setup/authentication.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 2 additions & 2 deletions apps/web/.env.bak
Original file line number Diff line number Diff line change
@@ -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_..."
Expand Down
18 changes: 15 additions & 3 deletions apps/web/.env.example
Original file line number Diff line number Diff line change
@@ -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_..."
Expand Down Expand Up @@ -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=""
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=""
4 changes: 2 additions & 2 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
66 changes: 21 additions & 45 deletions apps/web/src/app/(app)/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,60 +1,36 @@
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<{
children: React.ReactNode;
}>) {
const isDemoApp = process.env.NEXT_PUBLIC_DEMO_APP === "true";
return (
<html lang="en">
<head>
{process.env.NODE_ENV !== "production" && (
<script
crossOrigin="anonymous"
src="//unpkg.com/react-scan/dist/auto.global.js"
/>
)}
</head>
<body className={inter.className}>
{isDemoApp && (
<div className="fixed top-0 right-0 left-0 z-10 bg-[#CFC8FE] py-2 text-center text-black shadow-md">
You're currently using the demo application. To use your own agents,
and run in production, check out the{" "}
<a
className="underline underline-offset-2"
href={DOCS_LINK}
target="_blank"
rel="noopener noreferrer"
>
documentation
</a>
</div>
)}
<NuqsAdapter>
<AuthProvider>
<SidebarLayout>{children}</SidebarLayout>
</AuthProvider>
</NuqsAdapter>
</body>
</html>
<>
{isDemoApp && (
<div className="fixed top-0 right-0 left-0 z-10 bg-[#CFC8FE] py-2 text-center text-black shadow-md">
You're currently using the demo application. To use your own agents,
and run in production, check out the{" "}
<a
className="underline underline-offset-2"
href={DOCS_LINK}
target="_blank"
rel="noopener noreferrer"
>
documentation
</a>
</div>
)}
<NuqsAdapter>
<AuthProvider>
<SidebarLayout>{children}</SidebarLayout>
</AuthProvider>
</NuqsAdapter>
</>
);
}
197 changes: 197 additions & 0 deletions apps/web/src/app/api/mcp-servers/[id]/route.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> = {};

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 },
);
}
}
Loading