Skip to content

Commit 9eda826

Browse files
committed
feat(mcp): add ALLOWED_MCP_DOMAINS env var for domain allowlist
1 parent 61a5c98 commit 9eda826

File tree

11 files changed

+238
-43
lines changed

11 files changed

+238
-43
lines changed

apps/sim/app/api/mcp/servers/[id]/route.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { mcpServers } from '@sim/db/schema'
33
import { createLogger } from '@sim/logger'
44
import { and, eq, isNull } from 'drizzle-orm'
55
import type { NextRequest } from 'next/server'
6+
import { McpDomainNotAllowedError, validateMcpDomain } from '@/lib/mcp/domain-check'
67
import { getParsedBody, withMcpAuth } from '@/lib/mcp/middleware'
78
import { mcpService } from '@/lib/mcp/service'
89
import { createMcpErrorResponse, createMcpSuccessResponse } from '@/lib/mcp/utils'
@@ -29,6 +30,17 @@ export const PATCH = withMcpAuth<{ id: string }>('write')(
2930
// Remove workspaceId from body to prevent it from being updated
3031
const { workspaceId: _, ...updateData } = body
3132

33+
if (updateData.url) {
34+
try {
35+
validateMcpDomain(updateData.url)
36+
} catch (e) {
37+
if (e instanceof McpDomainNotAllowedError) {
38+
return createMcpErrorResponse(e, e.message, 403)
39+
}
40+
throw e
41+
}
42+
}
43+
3244
// Get the current server to check if URL is changing
3345
const [currentServer] = await db
3446
.select({ url: mcpServers.url })

apps/sim/app/api/mcp/servers/route.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { mcpServers } from '@sim/db/schema'
33
import { createLogger } from '@sim/logger'
44
import { and, eq, isNull } from 'drizzle-orm'
55
import type { NextRequest } from 'next/server'
6+
import { McpDomainNotAllowedError, validateMcpDomain } from '@/lib/mcp/domain-check'
67
import { getParsedBody, withMcpAuth } from '@/lib/mcp/middleware'
78
import { mcpService } from '@/lib/mcp/service'
89
import {
@@ -72,6 +73,15 @@ export const POST = withMcpAuth('write')(
7273
)
7374
}
7475

76+
try {
77+
validateMcpDomain(body.url)
78+
} catch (e) {
79+
if (e instanceof McpDomainNotAllowedError) {
80+
return createMcpErrorResponse(e, e.message, 403)
81+
}
82+
throw e
83+
}
84+
7585
const serverId = body.url ? generateMcpServerId(workspaceId, body.url) : crypto.randomUUID()
7686

7787
const [existingServer] = await db

apps/sim/app/api/mcp/servers/test-connection/route.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { createLogger } from '@sim/logger'
22
import type { NextRequest } from 'next/server'
33
import { McpClient } from '@/lib/mcp/client'
4+
import { McpDomainNotAllowedError, validateMcpDomain } from '@/lib/mcp/domain-check'
45
import { getParsedBody, withMcpAuth } from '@/lib/mcp/middleware'
56
import { resolveMcpConfigEnvVars } from '@/lib/mcp/resolve-config'
67
import type { McpTransport } from '@/lib/mcp/types'
@@ -71,6 +72,15 @@ export const POST = withMcpAuth('write')(
7172
)
7273
}
7374

75+
try {
76+
validateMcpDomain(body.url)
77+
} catch (e) {
78+
if (e instanceof McpDomainNotAllowedError) {
79+
return createMcpErrorResponse(e, e.message, 403)
80+
}
81+
throw e
82+
}
83+
7484
// Build initial config for resolution
7585
const initialConfig = {
7686
id: `test-${requestId}`,
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { NextResponse } from 'next/server'
2+
import { getAllowedMcpDomainsFromEnv } from '@/lib/core/config/feature-flags'
3+
import { getBaseUrl } from '@/lib/core/utils/urls'
4+
5+
export async function GET() {
6+
const configuredDomains = getAllowedMcpDomainsFromEnv()
7+
if (configuredDomains === null) {
8+
return NextResponse.json({ allowedMcpDomains: null })
9+
}
10+
11+
try {
12+
const platformHostname = new URL(getBaseUrl()).hostname.toLowerCase()
13+
if (!configuredDomains.includes(platformHostname)) {
14+
return NextResponse.json({
15+
allowedMcpDomains: [...configuredDomains, platformHostname],
16+
})
17+
}
18+
} catch {}
19+
20+
return NextResponse.json({ allowedMcpDomains: configuredDomains })
21+
}

apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/mcp/mcp.tsx

Lines changed: 45 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,21 @@ interface McpServer {
106106

107107
const logger = createLogger('McpSettings')
108108

109+
/**
110+
* Checks if a URL's hostname is in the allowed domains list.
111+
* Returns true if no allowlist is configured (null) or the domain matches.
112+
*/
113+
function isDomainAllowed(url: string | undefined, allowedDomains: string[] | null): boolean {
114+
if (allowedDomains === null) return true
115+
if (!url) return true
116+
try {
117+
const hostname = new URL(url).hostname.toLowerCase()
118+
return allowedDomains.includes(hostname)
119+
} catch {
120+
return true
121+
}
122+
}
123+
109124
const DEFAULT_FORM_DATA: McpServerFormData = {
110125
name: '',
111126
transport: 'streamable-http',
@@ -390,6 +405,15 @@ export function MCP({ initialServerId }: MCPProps) {
390405
} = useMcpServerTest()
391406
const availableEnvVars = useAvailableEnvVarKeys(workspaceId)
392407

408+
const [allowedMcpDomains, setAllowedMcpDomains] = useState<string[] | null>(null)
409+
410+
useEffect(() => {
411+
fetch('/api/settings/allowed-mcp-domains')
412+
.then((res) => res.json())
413+
.then((data) => setAllowedMcpDomains(data.allowedMcpDomains ?? null))
414+
.catch(() => setAllowedMcpDomains(null))
415+
}, [])
416+
393417
const urlInputRef = useRef<HTMLInputElement>(null)
394418

395419
const [showAddForm, setShowAddForm] = useState(false)
@@ -1006,10 +1030,12 @@ export function MCP({ initialServerId }: MCPProps) {
10061030
const showNoResults = searchTerm.trim() && filteredServers.length === 0 && servers.length > 0
10071031

10081032
const isFormValid = formData.name.trim() && formData.url?.trim()
1009-
const isSubmitDisabled = serversLoading || isAddingServer || !isFormValid
1033+
const isAddDomainBlocked = !isDomainAllowed(formData.url, allowedMcpDomains)
1034+
const isSubmitDisabled = serversLoading || isAddingServer || !isFormValid || isAddDomainBlocked
10101035
const testButtonLabel = getTestButtonLabel(testResult, isTestingConnection)
10111036

10121037
const isEditFormValid = editFormData.name.trim() && editFormData.url?.trim()
1038+
const isEditDomainBlocked = !isDomainAllowed(editFormData.url, allowedMcpDomains)
10131039
const editTestButtonLabel = getTestButtonLabel(editTestResult, isEditTestingConnection)
10141040
const hasEditChanges = useMemo(() => {
10151041
if (editFormData.name !== editOriginalData.name) return true
@@ -1299,6 +1325,11 @@ export function MCP({ initialServerId }: MCPProps) {
12991325
onChange={(e) => handleEditInputChange('url', e.target.value)}
13001326
onScroll={setEditUrlScrollLeft}
13011327
/>
1328+
{isEditDomainBlocked && (
1329+
<p className='mt-[4px] text-[12px] text-[var(--text-error)]'>
1330+
Domain not permitted by server policy
1331+
</p>
1332+
)}
13021333
</FormField>
13031334

13041335
<div className='flex flex-col gap-[8px]'>
@@ -1351,7 +1382,7 @@ export function MCP({ initialServerId }: MCPProps) {
13511382
<Button
13521383
variant='default'
13531384
onClick={handleEditTestConnection}
1354-
disabled={isEditTestingConnection || !isEditFormValid}
1385+
disabled={isEditTestingConnection || !isEditFormValid || isEditDomainBlocked}
13551386
>
13561387
{editTestButtonLabel}
13571388
</Button>
@@ -1361,7 +1392,9 @@ export function MCP({ initialServerId }: MCPProps) {
13611392
</Button>
13621393
<Button
13631394
onClick={handleSaveEdit}
1364-
disabled={!hasEditChanges || isUpdatingServer || !isEditFormValid}
1395+
disabled={
1396+
!hasEditChanges || isUpdatingServer || !isEditFormValid || isEditDomainBlocked
1397+
}
13651398
variant='tertiary'
13661399
>
13671400
{isUpdatingServer ? 'Saving...' : 'Save'}
@@ -1434,6 +1467,11 @@ export function MCP({ initialServerId }: MCPProps) {
14341467
onChange={(e) => handleInputChange('url', e.target.value)}
14351468
onScroll={(scrollLeft) => handleUrlScroll(scrollLeft)}
14361469
/>
1470+
{isAddDomainBlocked && (
1471+
<p className='mt-[4px] text-[12px] text-[var(--text-error)]'>
1472+
Domain not permitted by server policy
1473+
</p>
1474+
)}
14371475
</FormField>
14381476

14391477
<div className='flex flex-col gap-[8px]'>
@@ -1479,7 +1517,7 @@ export function MCP({ initialServerId }: MCPProps) {
14791517
<Button
14801518
variant='default'
14811519
onClick={handleTestConnection}
1482-
disabled={isTestingConnection || !isFormValid}
1520+
disabled={isTestingConnection || !isFormValid || isAddDomainBlocked}
14831521
>
14841522
{testButtonLabel}
14851523
</Button>
@@ -1489,7 +1527,9 @@ export function MCP({ initialServerId }: MCPProps) {
14891527
Cancel
14901528
</Button>
14911529
<Button onClick={handleAddServer} disabled={isSubmitDisabled} variant='tertiary'>
1492-
{isSubmitDisabled && isFormValid ? 'Adding...' : 'Add Server'}
1530+
{isSubmitDisabled && isFormValid && !isAddDomainBlocked
1531+
? 'Adding...'
1532+
: 'Add Server'}
14931533
</Button>
14941534
</div>
14951535
</div>

apps/sim/ee/sso/components/sso-settings.tsx

Lines changed: 25 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import { useState } from 'react'
44
import { createLogger } from '@sim/logger'
5-
import { Check, ChevronDown, Copy, Eye, EyeOff } from 'lucide-react'
5+
import { Check, ChevronDown, Clipboard, Eye, EyeOff } from 'lucide-react'
66
import { Button, Combobox, Input, Switch, Textarea } from '@/components/emcn'
77
import { Skeleton } from '@/components/ui'
88
import { useSession } from '@/lib/auth/auth-client'
@@ -418,29 +418,29 @@ export function SSO() {
418418

419419
{/* Callback URL */}
420420
<div className='flex flex-col gap-[8px]'>
421-
<span className='font-medium text-[13px] text-[var(--text-secondary)]'>
422-
Callback URL
423-
</span>
424-
<div className='relative'>
425-
<div className='flex h-9 items-center rounded-[6px] border bg-[var(--surface-1)] px-[10px] pr-[40px]'>
426-
<code className='flex-1 truncate font-mono text-[13px] text-[var(--text-primary)]'>
427-
{providerCallbackUrl}
428-
</code>
429-
</div>
421+
<div className='flex items-center justify-between'>
422+
<span className='font-medium text-[13px] text-[var(--text-secondary)]'>
423+
Callback URL
424+
</span>
430425
<Button
431426
type='button'
432427
variant='ghost'
433428
onClick={() => copyToClipboard(providerCallbackUrl)}
434-
className='-translate-y-1/2 absolute top-1/2 right-[4px] h-[28px] w-[28px] rounded-[4px] text-[var(--text-muted)] hover:text-[var(--text-primary)]'
429+
className='h-[22px] w-[22px] rounded-[4px] p-0 text-[var(--text-muted)] hover:text-[var(--text-primary)]'
435430
>
436431
{copied ? (
437-
<Check className='h-[14px] w-[14px]' />
432+
<Check className='h-[13px] w-[13px]' />
438433
) : (
439-
<Copy className='h-[14px] w-[14px]' />
434+
<Clipboard className='h-[13px] w-[13px]' />
440435
)}
441436
<span className='sr-only'>Copy callback URL</span>
442437
</Button>
443438
</div>
439+
<div className='flex h-9 items-center rounded-[6px] border bg-[var(--surface-1)] px-[10px]'>
440+
<code className='flex-1 truncate font-mono text-[13px] text-[var(--text-primary)]'>
441+
{providerCallbackUrl}
442+
</code>
443+
</div>
444444
<p className='text-[13px] text-[var(--text-muted)]'>
445445
Configure this in your identity provider
446446
</p>
@@ -852,29 +852,29 @@ export function SSO() {
852852

853853
{/* Callback URL display */}
854854
<div className='flex flex-col gap-[8px]'>
855-
<span className='font-medium text-[13px] text-[var(--text-secondary)]'>
856-
Callback URL
857-
</span>
858-
<div className='relative'>
859-
<div className='flex h-9 items-center rounded-[6px] border bg-[var(--surface-1)] px-[10px] pr-[40px]'>
860-
<code className='flex-1 truncate font-mono text-[13px] text-[var(--text-primary)]'>
861-
{callbackUrl}
862-
</code>
863-
</div>
855+
<div className='flex items-center justify-between'>
856+
<span className='font-medium text-[13px] text-[var(--text-secondary)]'>
857+
Callback URL
858+
</span>
864859
<Button
865860
type='button'
866861
variant='ghost'
867862
onClick={() => copyToClipboard(callbackUrl)}
868-
className='-translate-y-1/2 absolute top-1/2 right-[4px] h-[28px] w-[28px] rounded-[4px] text-[var(--text-muted)] hover:text-[var(--text-primary)]'
863+
className='h-[22px] w-[22px] rounded-[4px] p-0 text-[var(--text-muted)] hover:text-[var(--text-primary)]'
869864
>
870865
{copied ? (
871-
<Check className='h-[14px] w-[14px]' />
866+
<Check className='h-[13px] w-[13px]' />
872867
) : (
873-
<Copy className='h-[14px] w-[14px]' />
868+
<Clipboard className='h-[13px] w-[13px]' />
874869
)}
875870
<span className='sr-only'>Copy callback URL</span>
876871
</Button>
877872
</div>
873+
<div className='flex h-9 items-center rounded-[6px] border bg-[var(--surface-1)] px-[10px]'>
874+
<code className='flex-1 truncate font-mono text-[13px] text-[var(--text-primary)]'>
875+
{callbackUrl}
876+
</code>
877+
</div>
878878
<p className='text-[13px] text-[var(--text-muted)]'>
879879
Configure this in your identity provider
880880
</p>

apps/sim/lib/core/config/env.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,7 @@ export const env = createEnv({
9393
EXA_API_KEY: z.string().min(1).optional(), // Exa AI API key for enhanced online search
9494
BLACKLISTED_PROVIDERS: z.string().optional(), // Comma-separated provider IDs to hide (e.g., "openai,anthropic")
9595
BLACKLISTED_MODELS: z.string().optional(), // Comma-separated model names/prefixes to hide (e.g., "gpt-4,claude-*")
96+
ALLOWED_MCP_DOMAINS: z.string().optional(), // Comma-separated domains for MCP servers (e.g., "internal.company.com,mcp.example.org"). Empty = all allowed.
9697

9798
// Azure Configuration - Shared credentials with feature-specific models
9899
AZURE_OPENAI_ENDPOINT: z.string().url().optional(), // Shared Azure OpenAI service endpoint

apps/sim/lib/core/config/feature-flags.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,35 @@ export const isReactGrabEnabled = isDev && isTruthy(env.REACT_GRAB_ENABLED)
123123
*/
124124
export const isReactScanEnabled = isDev && isTruthy(env.REACT_SCAN_ENABLED)
125125

126+
/**
127+
* Normalizes a domain entry from the ALLOWED_MCP_DOMAINS env var.
128+
* Accepts bare hostnames (e.g., "mcp.company.com") or full URLs (e.g., "https://mcp.company.com").
129+
* Extracts the hostname in either case.
130+
*/
131+
function normalizeDomainEntry(entry: string): string {
132+
const trimmed = entry.trim().toLowerCase()
133+
if (!trimmed) return ''
134+
if (trimmed.includes('://')) {
135+
try {
136+
return new URL(trimmed).hostname
137+
} catch {
138+
return trimmed
139+
}
140+
}
141+
return trimmed
142+
}
143+
144+
/**
145+
* Get allowed MCP server domains from the ALLOWED_MCP_DOMAINS env var.
146+
* Returns null if not set (all domains allowed), or parsed array of lowercase hostnames.
147+
* Accepts both bare hostnames and full URLs in the env var value.
148+
*/
149+
export function getAllowedMcpDomainsFromEnv(): string[] | null {
150+
if (!env.ALLOWED_MCP_DOMAINS) return null
151+
const parsed = env.ALLOWED_MCP_DOMAINS.split(',').map(normalizeDomainEntry).filter(Boolean)
152+
return parsed.length > 0 ? parsed : null
153+
}
154+
126155
/**
127156
* Get cost multiplier based on environment
128157
*/

0 commit comments

Comments
 (0)