Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
17 changes: 17 additions & 0 deletions apps/sim/app/api/mcp/tools/discover/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,11 @@ vi.mock('@/lib/mcp/service', () => mcpServiceMock)

import { StreamableHTTPError } from '@modelcontextprotocol/sdk/client/streamableHttp.js'
import { ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js'
import {
McpDnsResolutionError,
McpDomainNotAllowedError,
McpSsrfError,
} from '@/lib/mcp/domain-check'
import { McpOauthRedirectRequired } from '@/lib/mcp/oauth'
import { McpConnectionError, McpServerCooldownError } from '@/lib/mcp/types'
import { GET } from '@/app/api/mcp/tools/discover/route'
Expand Down Expand Up @@ -56,6 +61,7 @@ describe('GET /api/mcp/tools/discover', () => {
)

it.each([
['hostname no longer resolves', () => new McpDnsResolutionError('expired.example.com')],
['cannot be reached', () => new McpConnectionError('connect ECONNREFUSED', 'Docs')],
['is cooling down after a recent failure', () => new McpServerCooldownError('mcp-1')],
['times out on tools/list', () => new McpError(ErrorCode.RequestTimeout, 'Request timed out')],
Expand All @@ -72,6 +78,17 @@ describe('GET /api/mcp/tools/discover', () => {
expect((await response.json()).error).toBe('Failed to discover MCP tools')
})

it.each([
['resolves to a blocked address', () => new McpSsrfError('blocked address')],
['is outside ALLOWED_MCP_DOMAINS', () => new McpDomainNotAllowedError('internal.example.com')],
])('answers 403 when the egress policy refuses a server that %s', async (_name, makeError) => {
mcpServiceMockFns.mockDiscoverServerTools.mockRejectedValueOnce(makeError())

const response = await discover()

expect(response.status).toBe(403)
})

it('keeps a 500 for a failure that is not the registered server', async () => {
mcpServiceMockFns.mockDiscoverServerTools.mockRejectedValueOnce(new Error('pool exhausted'))

Expand Down
14 changes: 14 additions & 0 deletions apps/sim/app/api/v2/mcp-servers/[mcpServerId]/tools/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ vi.mock('@/lib/mcp/application/use-cases', () => mcpUseCasesMock)

import { NoWorkspaceAccessError, WorkspaceApiKeyAuthorizationError } from '@/lib/core/application'
import { OrchestrationError } from '@/lib/core/orchestration/types'
import { McpDnsResolutionError } from '@/lib/mcp/domain-check'
import { McpOauthRedirectRequired } from '@/lib/mcp/oauth'
import {
McpConnectionError,
Expand Down Expand Up @@ -149,6 +150,19 @@ describe('/api/v2/mcp-servers/[mcpServerId]/tools', () => {
expect(JSON.stringify(body)).not.toContain('ECONNREFUSED')
})

it('reports a server whose hostname no longer resolves as a retryable 503', async () => {
mockDiscoverMcpServerToolsUseCase.mockRejectedValueOnce(
new McpDnsResolutionError('expired.example.com')
)

const response = await GET(request(`workspaceId=${WORKSPACE_ID}`), { ...context })
const body = await response.json()

expect(response.status).toBe(503)
expect(body.error.code).toBe('SERVICE_UNAVAILABLE')
expect(JSON.stringify(body)).not.toContain('expired.example.com')
})

it('reports a stale OAuth grant as a 409 a client can branch on, never as a Sim credential failure', async () => {
mockDiscoverMcpServerToolsUseCase.mockRejectedValueOnce(
new McpOauthAuthorizationRequiredError(SERVER_ID, 'Docs server')
Expand Down
25 changes: 19 additions & 6 deletions apps/sim/lib/mcp/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@ import { McpError as McpSdkError } from '@modelcontextprotocol/sdk/types.js'
import { generateShortId } from '@sim/utils/id'
import { NextResponse } from 'next/server'
import { DEFAULT_EXECUTION_TIMEOUT_MS, isTimeoutError } from '@/lib/core/execution-limits'
import {
McpDnsResolutionError,
McpDomainNotAllowedError,
McpSsrfError,
} from '@/lib/mcp/domain-check'
import {
type McpApiResponse,
McpConnectionError,
Expand Down Expand Up @@ -139,16 +144,18 @@ export function mcpOrchestrationStatus(errorCode: string | undefined): number {
}

/**
* Reports whether a registered MCP server failed to answer usefully: it could not be
* reached, timed out, is cooling down after a recent failure, or answered `tools/list`
* with a protocol or HTTP error. Typed only, never matched on message text, because
* `McpConnectionError` interpolates the server's display name into its message. Check
* authorization errors first: `UnauthorizedError` is not one of these, but a caller that
* wants to answer reauthorization separately must do so before asking.
* Reports whether a registered MCP server failed to answer usefully: its hostname no
* longer resolves, it could not be reached, timed out, is cooling down after a recent
* failure, or answered `tools/list` with a protocol or HTTP error. Typed only, never
* matched on message text, because `McpConnectionError` interpolates the server's display
* name into its message. Check authorization errors first: `UnauthorizedError` is not one
* of these, but a caller that wants to answer reauthorization separately must do so
* before asking.
*/
export function isMcpServerUnavailableError(error: unknown): boolean {
return (
isTimeoutError(error) ||
error instanceof McpDnsResolutionError ||
error instanceof McpConnectionError ||
error instanceof McpSdkError ||
error instanceof StreamableHTTPError
Expand All @@ -174,6 +181,12 @@ export function categorizeError(error: unknown): { message: string; status: numb
if (error instanceof McpConnectionError) {
return { message: 'Connection failed', status: 502 }
}
if (error instanceof McpDnsResolutionError) {
return { message: 'Server hostname could not be resolved', status: 502 }
}
if (error instanceof McpSsrfError || error instanceof McpDomainNotAllowedError) {
return { message: 'Server URL is not allowed by egress policy', status: 403 }
}

// Fall back to substring matching for SDK / third-party errors we don't
// own a typed class for.
Expand Down
Loading