chore: import upstream snapshot with attribution
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:20:55 +08:00
commit d25d482dc2
13754 changed files with 4996608 additions and 0 deletions
@@ -0,0 +1,4 @@
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { copilotMcpDeprecatedResponse } from '@/lib/mcp/copilot-deprecated'
export const GET = withRouteHandler(async () => copilotMcpDeprecatedResponse())
@@ -0,0 +1,4 @@
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { copilotMcpDeprecatedResponse } from '@/lib/mcp/copilot-deprecated'
export const GET = withRouteHandler(async () => copilotMcpDeprecatedResponse())
@@ -0,0 +1,62 @@
/**
* Tests for the deprecated Copilot MCP route
*
* @vitest-environment node
*/
import { NextRequest } from 'next/server'
import { describe, expect, it } from 'vitest'
import { GET as authServerDiscoveryGET } from '@/app/api/mcp/copilot/.well-known/oauth-authorization-server/route'
import { GET as protectedResourceDiscoveryGET } from '@/app/api/mcp/copilot/.well-known/oauth-protected-resource/route'
import { DELETE, GET, POST } from '@/app/api/mcp/copilot/route'
const URL = 'http://localhost:3000/api/mcp/copilot'
describe('Deprecated Copilot MCP route', () => {
it('GET returns 410', async () => {
const response = await GET(new NextRequest(URL))
expect(response.status).toBe(410)
})
it('POST returns 410 with a JSON-RPC error envelope', async () => {
const request = new NextRequest(URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize' }),
})
const response = await POST(request)
expect(response.status).toBe(410)
const body = (await response.json()) as { jsonrpc?: string; error?: { message?: string } }
expect(body.jsonrpc).toBe('2.0')
expect(body.error?.message).toContain('deprecated')
})
it('POST still returns 410 when an x-api-key header is present', async () => {
const request = new NextRequest(URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'x-api-key': 'sk-sim-copilot-test' },
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/list' }),
})
const response = await POST(request)
expect(response.status).toBe(410)
})
it('DELETE returns 410', async () => {
const response = await DELETE(new NextRequest(URL, { method: 'DELETE' }))
expect(response.status).toBe(410)
})
it('copilot OAuth authorization-server discovery returns 410', async () => {
const response = await authServerDiscoveryGET(
new NextRequest(`${URL}/.well-known/oauth-authorization-server`)
)
expect(response.status).toBe(410)
})
it('copilot OAuth protected-resource discovery returns 410', async () => {
const response = await protectedResourceDiscoveryGET(
new NextRequest(`${URL}/.well-known/oauth-protected-resource`)
)
expect(response.status).toBe(410)
})
})
+13
View File
@@ -0,0 +1,13 @@
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import {
copilotMcpDeprecatedJsonRpcResponse,
copilotMcpDeprecatedResponse,
} from '@/lib/mcp/copilot-deprecated'
export const dynamic = 'force-dynamic'
export const GET = withRouteHandler(async () => copilotMcpDeprecatedResponse())
export const POST = withRouteHandler(async () => copilotMcpDeprecatedJsonRpcResponse())
export const DELETE = withRouteHandler(async () => copilotMcpDeprecatedJsonRpcResponse())
+105
View File
@@ -0,0 +1,105 @@
import { db } from '@sim/db'
import { workflowMcpServer, workspace } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq, isNull, sql } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { checkHybridAuth } from '@/lib/auth/hybrid'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { listAccessibleWorkspaceRowsForUser } from '@/lib/workspaces/utils'
const logger = createLogger('McpDiscoverAPI')
export const dynamic = 'force-dynamic'
/**
* Discover all MCP servers available to the authenticated user.
*/
export const GET = withRouteHandler(async (request: NextRequest) => {
try {
const auth = await checkHybridAuth(request, { requireWorkflowId: false })
if (!auth.success || !auth.userId) {
return NextResponse.json(
{ success: false, error: 'Authentication required. Provide X-API-Key header.' },
{ status: 401 }
)
}
const userId = auth.userId
if (auth.apiKeyType === 'workspace' && !auth.workspaceId) {
return NextResponse.json(
{ success: false, error: 'Workspace API key missing workspace scope' },
{ status: 403 }
)
}
const accessibleRows = await listAccessibleWorkspaceRowsForUser(userId)
const accessibleWorkspaceIds = accessibleRows.map((row) => row.workspace.id)
const workspaceIds =
auth.apiKeyType === 'workspace' && auth.workspaceId
? accessibleWorkspaceIds.filter((workspaceId) => workspaceId === auth.workspaceId)
: accessibleWorkspaceIds
if (workspaceIds.length === 0) {
return NextResponse.json({ success: true, servers: [] })
}
const servers = await db
.select({
id: workflowMcpServer.id,
name: workflowMcpServer.name,
description: workflowMcpServer.description,
workspaceId: workflowMcpServer.workspaceId,
workspaceName: workspace.name,
createdAt: workflowMcpServer.createdAt,
toolCount: sql<number>`(
SELECT COUNT(*)::int
FROM "workflow_mcp_tool"
WHERE "workflow_mcp_tool"."server_id" = "workflow_mcp_server"."id"
AND "workflow_mcp_tool"."archived_at" IS NULL
)`.as('tool_count'),
})
.from(workflowMcpServer)
.leftJoin(workspace, eq(workflowMcpServer.workspaceId, workspace.id))
.where(
and(
sql`${workflowMcpServer.workspaceId} IN ${workspaceIds}`,
isNull(workflowMcpServer.deletedAt),
isNull(workspace.archivedAt)
)
)
.orderBy(workflowMcpServer.name)
const baseUrl = getBaseUrl()
const formattedServers = servers.map((server) => ({
id: server.id,
name: server.name,
description: server.description,
workspace: { id: server.workspaceId, name: server.workspaceName },
toolCount: server.toolCount || 0,
createdAt: server.createdAt,
url: `${baseUrl}/api/mcp/serve/${server.id}`,
}))
logger.info(`User ${userId} discovered ${formattedServers.length} MCP servers`)
return NextResponse.json({
success: true,
servers: formattedServers,
authentication: {
method: 'API Key',
header: 'X-API-Key',
},
})
} catch (error) {
logger.error('Error discovering MCP servers:', error)
return NextResponse.json(
{ success: false, error: 'Failed to discover MCP servers' },
{ status: 500 }
)
}
})
+121
View File
@@ -0,0 +1,121 @@
/**
* Tests for MCP SSE events endpoint
*
* @vitest-environment node
*/
import { authMockFns, permissionsMock, permissionsMockFns } from '@sim/testing'
import { NextRequest } from 'next/server'
import { beforeEach, describe, expect, it, vi } from 'vitest'
function createNextRequest(url: string): NextRequest {
return new NextRequest(new URL(url))
}
const mockGetUserEntityPermissions = permissionsMockFns.mockGetUserEntityPermissions
vi.mock('@/lib/workspaces/permissions/utils', () => permissionsMock)
vi.mock('@/lib/events/sse-endpoint', () => ({
createWorkspaceSSE: (_config: any) => {
return async (request: any) => {
const session = await authMockFns.mockGetSession()
if (!session?.user?.id) {
return new Response('Unauthorized', { status: 401 })
}
const url = new URL(request.url)
const workspaceId = url.searchParams.get('workspaceId')
if (!workspaceId) {
return new Response('Missing workspaceId query parameter', { status: 400 })
}
const permissions = await mockGetUserEntityPermissions(
session.user.id,
'workspace',
workspaceId
)
if (!permissions) {
return new Response('Access denied to workspace', { status: 403 })
}
return new Response(new ReadableStream({ start() {} }), {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
},
})
}
},
}))
vi.mock('@/lib/mcp/connection-manager', () => ({
mcpConnectionManager: null,
}))
vi.mock('@/lib/mcp/pubsub', () => ({
mcpPubSub: null,
}))
import { GET } from './route'
const defaultMockUser = {
id: 'user-123',
email: 'test@example.com',
name: 'Test User',
}
describe('MCP Events SSE Endpoint', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('returns 401 when session is missing', async () => {
authMockFns.mockGetSession.mockResolvedValue(null)
const request = createNextRequest('http://localhost:3000/api/mcp/events?workspaceId=ws-123')
const response = await GET(request)
expect(response.status).toBe(401)
const text = await response.text()
expect(text).toBe('Unauthorized')
})
it('returns 400 when workspaceId is missing', async () => {
authMockFns.mockGetSession.mockResolvedValue({ user: defaultMockUser })
const request = createNextRequest('http://localhost:3000/api/mcp/events')
const response = await GET(request)
expect(response.status).toBe(400)
const text = await response.text()
expect(text).toBe('Missing workspaceId query parameter')
})
it('returns 403 when user lacks workspace access', async () => {
authMockFns.mockGetSession.mockResolvedValue({ user: defaultMockUser })
mockGetUserEntityPermissions.mockResolvedValue(null)
const request = createNextRequest('http://localhost:3000/api/mcp/events?workspaceId=ws-123')
const response = await GET(request)
expect(response.status).toBe(403)
const text = await response.text()
expect(text).toBe('Access denied to workspace')
expect(mockGetUserEntityPermissions).toHaveBeenCalledWith('user-123', 'workspace', 'ws-123')
})
it('returns SSE stream when authorized', async () => {
authMockFns.mockGetSession.mockResolvedValue({ user: defaultMockUser })
mockGetUserEntityPermissions.mockResolvedValue({ read: true })
const request = createNextRequest('http://localhost:3000/api/mcp/events?workspaceId=ws-123')
const response = await GET(request)
expect(response.status).toBe(200)
expect(response.headers.get('Content-Type')).toBe('text/event-stream')
expect(response.headers.get('Cache-Control')).toBe('no-cache')
expect(response.headers.get('Connection')).toBe('keep-alive')
})
})
+62
View File
@@ -0,0 +1,62 @@
/**
* SSE endpoint for MCP tool-change events.
*
* Pushes `tools_changed` events to the browser when:
* - An external MCP server sends `notifications/tools/list_changed` (via connection manager)
* - A workflow CRUD route modifies workflow MCP server tools (via pub/sub)
*
* Auth is handled via session cookies (EventSource sends cookies automatically).
*/
import type { NextRequest } from 'next/server'
import { mcpEventsQuerySchema } from '@/lib/api/contracts/mcp'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { createWorkspaceSSE } from '@/lib/events/sse-endpoint'
import { mcpConnectionManager } from '@/lib/mcp/connection-manager'
import { mcpPubSub } from '@/lib/mcp/pubsub'
export const dynamic = 'force-dynamic'
const mcpEventsHandler = createWorkspaceSSE({
label: 'mcp-events',
subscriptions: [
{
subscribe: (workspaceId, send) => {
if (!mcpConnectionManager) return () => {}
return mcpConnectionManager.subscribe((event) => {
if (event.workspaceId !== workspaceId) return
send('tools_changed', {
source: 'external',
serverId: event.serverId,
timestamp: event.timestamp,
})
})
},
},
{
subscribe: (workspaceId, send) => {
if (!mcpPubSub) return () => {}
return mcpPubSub.onWorkflowToolsChanged((event) => {
if (event.workspaceId !== workspaceId) return
send('tools_changed', {
source: 'workflow',
serverId: event.serverId,
timestamp: Date.now(),
})
})
},
},
],
})
export const GET = withRouteHandler(async (request: NextRequest) => {
const queryValidation = mcpEventsQuerySchema.safeParse({
workspaceId: request.nextUrl.searchParams.get('workspaceId'),
})
if (!queryValidation.success || !queryValidation.data.workspaceId) {
return new Response('Missing workspaceId query parameter', { status: 400 })
}
return mcpEventsHandler(request)
})
@@ -0,0 +1,75 @@
/**
* @vitest-environment node
*/
import {
authMockFns,
dbChainMock,
dbChainMockFns,
mcpOauthMock,
mcpOauthMockFns,
resetDbChainMock,
schemaMock,
} from '@sim/testing'
import { NextRequest } from 'next/server'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { mockDiscoverServerTools } = vi.hoisted(() => ({
mockDiscoverServerTools: vi.fn(),
}))
vi.mock('@sim/db', () => dbChainMock)
vi.mock('@sim/db/schema', () => schemaMock)
vi.mock('drizzle-orm', () => ({
and: vi.fn(),
eq: vi.fn(),
isNull: vi.fn(),
}))
vi.mock('@/lib/mcp/oauth', () => mcpOauthMock)
vi.mock('@/lib/mcp/service', () => ({
mcpService: { discoverServerTools: mockDiscoverServerTools },
}))
import { GET } from './route'
describe('MCP OAuth callback route', () => {
beforeEach(() => {
vi.clearAllMocks()
resetDbChainMock()
authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-1' } })
mcpOauthMockFns.mockLoadOauthRowByState.mockResolvedValue({
id: 'oauth-row-1',
mcpServerId: 'server-1',
userId: 'user-1',
workspaceId: 'workspace-1',
})
dbChainMockFns.limit.mockResolvedValue([
{
id: 'server-1',
url: 'https://mcp.example.com/mcp',
workspaceId: 'workspace-1',
},
])
mcpOauthMockFns.mockLoadPreregisteredClient.mockResolvedValue(undefined)
mcpOauthMockFns.mockMcpAuthGuarded.mockResolvedValue('AUTHORIZED')
mockDiscoverServerTools.mockResolvedValue(undefined)
})
it('performs the token exchange through the SSRF-guarded mcpAuthGuarded wrapper', async () => {
const request = new NextRequest(
'http://localhost:3000/api/mcp/oauth/callback?state=state-1&code=auth-code-1'
)
await GET(request)
// The route must call the guarded wrapper (which defaults fetchFn to the
// SSRF-guarded fetch internally) rather than the raw SDK `auth()` — see
// apps/sim/lib/mcp/oauth/auth.test.ts for the wrapper's own fetchFn coverage.
expect(mcpOauthMockFns.mockMcpAuthGuarded).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
serverUrl: 'https://mcp.example.com/mcp',
authorizationCode: 'auth-code-1',
})
)
})
})
@@ -0,0 +1,181 @@
import { db } from '@sim/db'
import { mcpServers } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import { and, eq, isNull } from 'drizzle-orm'
import type { NextRequest } from 'next/server'
import { NextResponse } from 'next/server'
import { mcpOauthCallbackContract } from '@/lib/api/contracts/mcp'
import { parseRequest } from '@/lib/api/server'
import { getSession } from '@/lib/auth'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import {
assertSafeOauthServerUrl,
clearState,
clearVerifier,
loadOauthRowByState,
loadPreregisteredClient,
type McpOauthCallbackReason,
mcpAuthGuarded,
SimMcpOauthProvider,
} from '@/lib/mcp/oauth'
import { mcpService } from '@/lib/mcp/service'
const logger = createLogger('McpOauthCallbackAPI')
export const dynamic = 'force-dynamic'
function escapeHtml(value: string): string {
return value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
}
function jsonLiteral(value: string | undefined): string {
if (value === undefined) return 'undefined'
return JSON.stringify(value).replace(/</g, '\\u003c').replace(/>/g, '\\u003e')
}
function htmlClose(
message: string,
ok: boolean,
reason: McpOauthCallbackReason,
serverId?: string
): NextResponse {
const safeMessage = escapeHtml(message)
const title = ok ? 'Connected' : 'Connection failed'
const body = `<!doctype html><html><head><meta charset="utf-8"><title>${title}</title></head><body style="font-family: system-ui; padding: 24px"><p>${safeMessage}</p><script>
try { window.opener && window.opener.postMessage({ type: 'mcp-oauth', ok: ${ok ? 'true' : 'false'}, serverId: ${jsonLiteral(serverId)}, reason: ${jsonLiteral(reason)} }, window.location.origin) } catch (e) {}
setTimeout(function () { window.close() }, 800)
</script></body></html>`
return new NextResponse(body, {
headers: { 'Content-Type': 'text/html; charset=utf-8' },
})
}
export const GET = withRouteHandler(async (request: NextRequest) => {
const parsed = await parseRequest(mcpOauthCallbackContract, request, {})
if (!parsed.success) {
return htmlClose('Malformed authorization callback.', false, 'missing_params')
}
const { state, code, error: errorParam } = parsed.data.query
const initialRow = state ? await loadOauthRowByState(state).catch(() => null) : null
const stateRowServerId = initialRow?.mcpServerId
if (errorParam) {
logger.warn(`MCP OAuth callback received error: ${errorParam}`)
if (initialRow) await clearState(initialRow.id).catch(() => {})
return htmlClose(
`Authorization failed: ${errorParam}`,
false,
'provider_error',
stateRowServerId
)
}
if (!state || !code) {
return htmlClose(
'Missing state or code in callback URL.',
false,
'missing_params',
stateRowServerId
)
}
let serverId: string | undefined
try {
const session = await getSession()
if (!session?.user?.id) {
return htmlClose(
'You must be signed in to complete authorization.',
false,
'unauthenticated',
stateRowServerId
)
}
const row = initialRow
if (!row) {
return htmlClose('Invalid or expired authorization state.', false, 'invalid_state')
}
serverId = row.mcpServerId
if (session.user.id !== row.userId) {
return htmlClose(
'You must be signed in as the same user that initiated the flow.',
false,
'user_mismatch',
serverId
)
}
const [server] = await db
.select({ id: mcpServers.id, url: mcpServers.url, workspaceId: mcpServers.workspaceId })
.from(mcpServers)
.where(and(eq(mcpServers.id, row.mcpServerId), isNull(mcpServers.deletedAt)))
.limit(1)
if (!server || !server.url) {
return htmlClose('Server no longer exists.', false, 'server_gone', serverId)
}
if (server.workspaceId !== row.workspaceId) {
return htmlClose(
'Workspace mismatch on authorization callback.',
false,
'invalid_state',
serverId
)
}
try {
assertSafeOauthServerUrl(server.url)
} catch {
return htmlClose(
'MCP OAuth requires https (or http://localhost for development).',
false,
'insecure_url',
serverId
)
}
// Burn state before token exchange so a replayed callback cannot reuse it.
await clearState(row.id)
const preregistered = await loadPreregisteredClient(server.id)
const provider = new SimMcpOauthProvider({ row, preregistered })
let result: Awaited<ReturnType<typeof mcpAuthGuarded>>
try {
result = await mcpAuthGuarded(provider, {
serverUrl: server.url,
authorizationCode: code,
})
} catch (e) {
logger.error('Token exchange failed during MCP OAuth callback', e)
return htmlClose(
'Token exchange failed. Please try again.',
false,
'token_exchange_failed',
server.id
)
} finally {
await clearVerifier(row.id)
}
if (result !== 'AUTHORIZED') {
return htmlClose('Authorization did not complete.', false, 'token_exchange_failed', server.id)
}
try {
// forceRefresh: skip any stale cache from before re-auth.
await mcpService.discoverServerTools(session.user.id, server.id, server.workspaceId, true)
} catch (e) {
logger.warn('Post-auth tools refresh failed', toError(e).message)
}
return htmlClose('Connected. You can close this window.', true, 'authorized', server.id)
} catch (error) {
logger.error('MCP OAuth callback failed', error)
return htmlClose('Authorization failed. Please try again.', false, 'unknown', serverId)
}
})
@@ -0,0 +1,209 @@
/**
* @vitest-environment node
*/
import {
dbChainMock,
dbChainMockFns,
hybridAuthMock,
hybridAuthMockFns,
McpOauthRedirectRequiredMock,
mcpOauthMock,
mcpOauthMockFns,
permissionsMock,
permissionsMockFns,
resetDbChainMock,
schemaMock,
} from '@sim/testing'
import { NextRequest } from 'next/server'
import { beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('@sim/db', () => dbChainMock)
vi.mock('@sim/db/schema', () => schemaMock)
vi.mock('drizzle-orm', () => ({
and: vi.fn(),
eq: vi.fn(),
isNull: vi.fn(),
}))
vi.mock('@/lib/auth/hybrid', () => hybridAuthMock)
vi.mock('@/lib/workspaces/permissions/utils', () => permissionsMock)
vi.mock('@/lib/mcp/oauth', () => mcpOauthMock)
import { GET, surfaceOauthError } from './route'
describe('MCP OAuth start route', () => {
beforeEach(() => {
vi.clearAllMocks()
resetDbChainMock()
hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({
success: true,
userId: 'user-2',
userName: 'User Two',
userEmail: 'user2@example.com',
authType: 'session',
})
permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValue('write')
dbChainMockFns.limit.mockResolvedValue([
{
id: 'server-1',
name: 'Exa',
url: 'https://mcp.exa.ai/mcp',
workspaceId: 'workspace-1',
authType: 'oauth',
deletedAt: null,
},
])
mcpOauthMockFns.mockGetOrCreateOauthRow.mockResolvedValue({
id: 'oauth-row-1',
mcpServerId: 'server-1',
userId: 'user-1',
workspaceId: 'workspace-1',
clientInformation: null,
tokens: null,
codeVerifier: null,
state: null,
stateCreatedAt: null,
updatedAt: new Date(),
})
mcpOauthMockFns.mockLoadPreregisteredClient.mockResolvedValue(undefined)
mcpOauthMockFns.mockMcpAuthGuarded.mockRejectedValue(
new McpOauthRedirectRequiredMock('https://mcp.exa.ai/authorize')
)
})
it('routes OAuth discovery through the SSRF-guarded mcpAuthGuarded wrapper', async () => {
const request = new NextRequest(
'http://localhost:3000/api/mcp/oauth/start?workspaceId=workspace-1&serverId=server-1'
)
await GET(request)
// The route must call the guarded wrapper (which defaults fetchFn to the
// SSRF-guarded fetch internally) rather than the raw SDK `auth()` — see
// apps/sim/lib/mcp/oauth/auth.test.ts for the wrapper's own fetchFn coverage.
expect(mcpOauthMockFns.mockMcpAuthGuarded).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ serverUrl: 'https://mcp.exa.ai/mcp' })
)
})
it('requires workspace write permission via MCP auth middleware', async () => {
const request = new NextRequest(
'http://localhost:3000/api/mcp/oauth/start?workspaceId=workspace-1&serverId=server-1'
)
await GET(request)
expect(permissionsMockFns.mockGetUserEntityPermissions).toHaveBeenCalledWith(
'user-2',
'workspace',
'workspace-1'
)
})
it('uses a workspace-scoped OAuth row and stamps the latest authorizing user', async () => {
const request = new NextRequest(
'http://localhost:3000/api/mcp/oauth/start?workspaceId=workspace-1&serverId=server-1'
)
const response = await GET(request)
const body = await response.json()
expect(response.status).toBe(200)
expect(body).toEqual({
status: 'redirect',
authorizationUrl: 'https://mcp.exa.ai/authorize',
})
expect(mcpOauthMockFns.mockGetOrCreateOauthRow).toHaveBeenCalledWith({
mcpServerId: 'server-1',
userId: 'user-2',
workspaceId: 'workspace-1',
})
expect(mcpOauthMockFns.mockSetOauthRowUser).toHaveBeenCalledWith('oauth-row-1', 'user-2')
})
it('rejects a second user starting OAuth while another authorization is active', async () => {
mcpOauthMockFns.mockGetOrCreateOauthRow.mockResolvedValueOnce({
id: 'oauth-row-1',
mcpServerId: 'server-1',
userId: 'user-1',
workspaceId: 'workspace-1',
clientInformation: null,
tokens: null,
codeVerifier: null,
state: 'hashed-active-state',
stateCreatedAt: new Date(),
updatedAt: new Date(),
})
const request = new NextRequest(
'http://localhost:3000/api/mcp/oauth/start?workspaceId=workspace-1&serverId=server-1'
)
const response = await GET(request)
const body = await response.json()
expect(response.status).toBe(409)
expect(body.error).toBe('OAuth authorization already in progress for this server')
expect(mcpOauthMockFns.mockMcpAuthGuarded).not.toHaveBeenCalled()
})
it('does not leak non-OAuth internal error details to the client', async () => {
mcpOauthMockFns.mockGetOrCreateOauthRow.mockRejectedValueOnce(
new Error('connect ECONNREFUSED 10.0.0.5:5432 (internal-db-host)')
)
const request = new NextRequest(
'http://localhost:3000/api/mcp/oauth/start?workspaceId=workspace-1&serverId=server-1'
)
const response = await GET(request)
const body = await response.json()
expect(response.status).toBe(500)
expect(body.error).toBe('Failed to start OAuth flow')
expect(body.error).not.toContain('ECONNREFUSED')
expect(body.error).not.toContain('internal-db-host')
})
})
describe('surfaceOauthError', () => {
it('uses typed OAuthError errorCode and message for spec-compliant errors', async () => {
const { InvalidGrantError } = await import('@modelcontextprotocol/sdk/server/auth/errors.js')
const err = new InvalidGrantError('Refresh token expired')
expect(surfaceOauthError(err)).toBe('invalid_grant: Refresh token expired')
})
it('parses Raw body envelope for ServerError fallbacks (non-spec vendors)', async () => {
const { ServerError } = await import('@modelcontextprotocol/sdk/server/auth/errors.js')
const err = new ServerError(
'HTTP 400: Invalid OAuth error response: zod error. Raw body: {"code":400,"message":"redirect URI https://example.com/cb is not allowed","retryable":false}'
)
expect(surfaceOauthError(err)).toBe(
'Authorization server: redirect URI https://example.com/cb is not allowed'
)
})
it('prefers error_description over message over error in fallback envelope', async () => {
const { ServerError } = await import('@modelcontextprotocol/sdk/server/auth/errors.js')
const err = new ServerError(
'HTTP 400: Invalid OAuth error response: zod. Raw body: {"error":"invalid_grant","error_description":"the description","message":"the message"}'
)
expect(surfaceOauthError(err)).toBe('Authorization server: the description')
})
it('returns first line of generic errors', () => {
const err = new Error('Network blip\n at fetch (...)')
expect(surfaceOauthError(err)).toBe('Network blip')
})
it('truncates messages longer than 250 chars with ellipsis', async () => {
const { InvalidGrantError } = await import('@modelcontextprotocol/sdk/server/auth/errors.js')
const longMessage = 'x'.repeat(300)
const result = surfaceOauthError(new InvalidGrantError(longMessage))
expect(result.endsWith('…')).toBe(true)
expect(result.length).toBe(251)
})
it('returns generic fallback for non-Error values', () => {
expect(surfaceOauthError(null)).toBe('Failed to start OAuth flow')
expect(surfaceOauthError(undefined)).toBe('Failed to start OAuth flow')
})
})
+162
View File
@@ -0,0 +1,162 @@
import { OAuthError, ServerError } from '@modelcontextprotocol/sdk/server/auth/errors.js'
import { db } from '@sim/db'
import { mcpServers } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import { and, eq, isNull } from 'drizzle-orm'
import type { NextRequest } from 'next/server'
import { NextResponse } from 'next/server'
import { startMcpOauthContract } from '@/lib/api/contracts/mcp'
import { parseRequest } from '@/lib/api/server'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { withMcpAuth } from '@/lib/mcp/middleware'
import {
assertSafeOauthServerUrl,
getOrCreateOauthRow,
loadPreregisteredClient,
McpOauthInsecureUrlError,
McpOauthRedirectRequired,
mcpAuthGuarded,
SimMcpOauthProvider,
setOauthRowUser,
} from '@/lib/mcp/oauth'
import { createMcpErrorResponse } from '@/lib/mcp/utils'
const logger = createLogger('McpOauthStartAPI')
const OAUTH_START_TTL_MS = 10 * 60 * 1000
const MAX_SURFACED_ERROR_LENGTH = 250
export function surfaceOauthError(error: unknown): string {
// Spec-compliant OAuth servers throw typed subclasses with clean RFC 6749 fields.
if (error instanceof OAuthError && !(error instanceof ServerError)) {
return truncate(`${error.errorCode}: ${error.message}`)
}
// ServerError wraps non-spec response bodies as "HTTP N: Invalid OAuth error
// response: ... Raw body: {...}". Dig the vendor message out of the JSON tail.
if (error instanceof Error) {
const rawBodyMatch = error.message.match(/Raw body:\s*(\{[\s\S]*\})\s*$/)
if (rawBodyMatch) {
try {
const body = JSON.parse(rawBodyMatch[1]) as Record<string, unknown>
const vendorMessage =
(typeof body.error_description === 'string' && body.error_description) ||
(typeof body.message === 'string' && body.message) ||
(typeof body.error === 'string' && body.error) ||
null
if (vendorMessage) return truncate(`Authorization server: ${vendorMessage}`)
} catch {}
}
return truncate(error.message.split('\n')[0] || 'Failed to start OAuth flow')
}
return 'Failed to start OAuth flow'
}
function truncate(message: string): string {
return message.length > MAX_SURFACED_ERROR_LENGTH
? `${message.slice(0, MAX_SURFACED_ERROR_LENGTH)}`
: message
}
export const dynamic = 'force-dynamic'
export const GET = withRouteHandler(
withMcpAuth('write')(async (request: NextRequest, { userId, workspaceId }) => {
try {
const parsed = await parseRequest(startMcpOauthContract, request, {})
if (!parsed.success) return parsed.response
const { serverId } = parsed.data.query
const [server] = await db
.select()
.from(mcpServers)
.where(
and(
eq(mcpServers.id, serverId),
eq(mcpServers.workspaceId, workspaceId),
isNull(mcpServers.deletedAt)
)
)
.limit(1)
if (!server) {
return createMcpErrorResponse(new Error('Server not found'), 'Server not found', 404)
}
if (server.authType !== 'oauth') {
return createMcpErrorResponse(
new Error(`Server authType is "${server.authType}", not oauth`),
'Server is not configured for OAuth',
400
)
}
if (!server.url) {
return createMcpErrorResponse(new Error('Server has no URL'), 'Missing server URL', 400)
}
try {
assertSafeOauthServerUrl(server.url)
} catch (e) {
if (e instanceof McpOauthInsecureUrlError) {
return createMcpErrorResponse(
e,
'MCP OAuth requires https (or http://localhost for development)',
400
)
}
throw e
}
const row = await getOrCreateOauthRow({
mcpServerId: server.id,
userId,
workspaceId,
})
const hasActiveFlow =
!!row.state &&
!!row.stateCreatedAt &&
row.stateCreatedAt.getTime() > Date.now() - OAUTH_START_TTL_MS
if (hasActiveFlow && row.userId && row.userId !== userId) {
return createMcpErrorResponse(
new Error('OAuth authorization already in progress'),
'OAuth authorization already in progress for this server',
409
)
}
if (row.userId !== userId) {
await setOauthRowUser(row.id, userId)
row.userId = userId
}
const preregistered = await loadPreregisteredClient(server.id)
const provider = new SimMcpOauthProvider({ row, preregistered })
try {
const result = await mcpAuthGuarded(provider, {
serverUrl: server.url,
})
if (result === 'AUTHORIZED') {
return NextResponse.json({ status: 'already_authorized' })
}
return createMcpErrorResponse(
new Error('Provider did not capture redirect URL'),
'Failed to start OAuth flow',
500
)
} catch (e) {
if (e instanceof McpOauthRedirectRequired) {
logger.info(`OAuth redirect for server ${serverId}`)
return NextResponse.json({
status: 'redirect',
authorizationUrl: e.authorizationUrl,
})
}
throw e
}
} catch (error) {
logger.error('Error starting MCP OAuth flow:', error)
// Only surface OAuth-flow errors verbatim; everything else (DB, decryption,
// network) gets a generic message to avoid leaking internal details.
const userMessage =
error instanceof OAuthError ? surfaceOauthError(error) : 'Failed to start OAuth flow'
return createMcpErrorResponse(toError(error), userMessage, 500)
}
})
)
@@ -0,0 +1,954 @@
/**
* Tests for MCP serve route auth propagation.
*
* @vitest-environment node
*/
import {
dbChainMock,
dbChainMockFns,
hybridAuthMockFns,
permissionsMock,
permissionsMockFns,
resetDbChainMock,
} from '@sim/testing'
import { NextRequest } from 'next/server'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const { mockGenerateInternalToken, fetchMock, mockIsWorkspaceApiExecutionEntitled } = vi.hoisted(
() => ({
mockGenerateInternalToken: vi.fn(),
fetchMock: vi.fn(),
mockIsWorkspaceApiExecutionEntitled: vi.fn().mockResolvedValue(true),
})
)
vi.mock('@/lib/billing/core/api-access', () => ({
API_EXECUTION_REQUIRES_PAID_PLAN_MESSAGE: 'paid plan required',
isWorkspaceApiExecutionEntitled: mockIsWorkspaceApiExecutionEntitled,
}))
const mockGetUserEntityPermissions = permissionsMockFns.mockGetUserEntityPermissions
const MCP_BYTE_LIMIT = 10 * 1024 * 1024
const MCP_TOOLS_LIST_LIMIT = 100
vi.mock('@sim/db', () => dbChainMock)
vi.mock('drizzle-orm', () => ({
and: vi.fn(),
asc: vi.fn(),
eq: vi.fn(),
gt: vi.fn(),
isNull: vi.fn(),
sql: vi.fn(),
}))
vi.mock('@/lib/workspaces/permissions/utils', () => permissionsMock)
vi.mock('@/lib/auth/internal', () => ({
generateInternalToken: mockGenerateInternalToken,
}))
vi.mock('@/lib/core/utils/urls', () => ({
getBaseUrl: () => 'http://localhost:3000',
getInternalApiBaseUrl: () => 'http://localhost:3000',
}))
vi.mock('@/lib/core/execution-limits', () => ({
getMaxExecutionTimeout: () => 10_000,
}))
import { DELETE, GET, POST } from '@/app/api/mcp/serve/[serverId]/route'
describe('MCP Serve Route', () => {
beforeEach(() => {
vi.clearAllMocks()
resetDbChainMock()
vi.stubGlobal('fetch', fetchMock)
})
afterEach(() => {
vi.unstubAllGlobals()
})
it('returns 401 for private server when auth fails', async () => {
dbChainMockFns.limit.mockResolvedValueOnce([
{
id: 'server-1',
name: 'Private Server',
workspaceId: 'ws-1',
isPublic: false,
createdBy: 'owner-1',
},
])
hybridAuthMockFns.mockCheckHybridAuth.mockResolvedValueOnce({
success: false,
error: 'Unauthorized',
})
const req = new NextRequest('http://localhost:3000/api/mcp/serve/server-1', {
method: 'POST',
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'ping' }),
})
const response = await POST(req, { params: Promise.resolve({ serverId: 'server-1' }) })
expect(response.status).toBe(401)
})
it('returns 402 when the workspace billed account is on the free plan', async () => {
dbChainMockFns.limit.mockResolvedValueOnce([
{
id: 'server-1',
name: 'Private Server',
workspaceId: 'ws-1',
isPublic: false,
createdBy: 'owner-1',
},
])
mockIsWorkspaceApiExecutionEntitled.mockResolvedValueOnce(false)
const req = new NextRequest('http://localhost:3000/api/mcp/serve/server-1', {
method: 'POST',
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'ping' }),
})
const response = await POST(req, { params: Promise.resolve({ serverId: 'server-1' }) })
expect(response.status).toBe(402)
})
it('returns 401 on GET for private server when auth fails', async () => {
dbChainMockFns.limit.mockResolvedValueOnce([
{
id: 'server-1',
name: 'Private Server',
workspaceId: 'ws-1',
isPublic: false,
createdBy: 'owner-1',
},
])
hybridAuthMockFns.mockCheckHybridAuth.mockResolvedValueOnce({
success: false,
error: 'Unauthorized',
})
const req = new NextRequest('http://localhost:3000/api/mcp/serve/server-1')
const response = await GET(req, { params: Promise.resolve({ serverId: 'server-1' }) })
expect(response.status).toBe(401)
})
it('allows unauthenticated GET metadata for public servers', async () => {
dbChainMockFns.limit.mockResolvedValueOnce([
{
id: 'server-1',
name: 'Public Server',
workspaceId: 'ws-1',
isPublic: true,
createdBy: 'owner-1',
},
])
const req = new NextRequest('http://localhost:3000/api/mcp/serve/server-1')
const response = await GET(req, { params: Promise.resolve({ serverId: 'server-1' }) })
const body = await response.json()
expect(response.status).toBe(200)
expect(body.name).toBe('Public Server')
expect(hybridAuthMockFns.mockCheckHybridAuth).not.toHaveBeenCalled()
})
it('authenticates private SSE-style GET before returning unsupported transport', async () => {
dbChainMockFns.limit.mockResolvedValueOnce([
{
id: 'server-1',
name: 'Private Server',
workspaceId: 'ws-1',
isPublic: false,
createdBy: 'owner-1',
},
])
hybridAuthMockFns.mockCheckHybridAuth.mockResolvedValueOnce({
success: false,
error: 'Unauthorized',
})
const req = new NextRequest('http://localhost:3000/api/mcp/serve/server-1', {
headers: { accept: 'text/event-stream' },
})
const response = await GET(req, { params: Promise.resolve({ serverId: 'server-1' }) })
expect(response.status).toBe(401)
})
it('returns 405 for authorized SSE-style GET', async () => {
dbChainMockFns.limit.mockResolvedValueOnce([
{
id: 'server-1',
name: 'Private Server',
workspaceId: 'ws-1',
isPublic: false,
createdBy: 'owner-1',
},
])
hybridAuthMockFns.mockCheckHybridAuth.mockResolvedValueOnce({
success: true,
userId: 'user-1',
authType: 'session',
})
mockGetUserEntityPermissions.mockResolvedValueOnce('read')
const req = new NextRequest('http://localhost:3000/api/mcp/serve/server-1', {
headers: { accept: 'text/event-stream' },
})
const response = await GET(req, { params: Promise.resolve({ serverId: 'server-1' }) })
const body = await response.json()
expect(response.status).toBe(405)
expect(response.headers.get('allow')).toBe('GET, POST, DELETE')
expect(body.error.code).toBe('unsupported_transport')
})
it('requires authentication for DELETE even on public servers', async () => {
dbChainMockFns.limit.mockResolvedValueOnce([
{
id: 'server-1',
name: 'Public Server',
workspaceId: 'ws-1',
isPublic: true,
createdBy: 'owner-1',
},
])
hybridAuthMockFns.mockCheckHybridAuth.mockResolvedValueOnce({
success: false,
error: 'Unauthorized',
})
const req = new NextRequest('http://localhost:3000/api/mcp/serve/server-1', {
method: 'DELETE',
})
const response = await DELETE(req, { params: Promise.resolve({ serverId: 'server-1' }) })
expect(response.status).toBe(401)
})
it('uses an internal bridge token for private server api_key auth', async () => {
dbChainMockFns.limit
.mockResolvedValueOnce([
{
id: 'server-1',
name: 'Private Server',
workspaceId: 'ws-1',
isPublic: false,
createdBy: 'owner-1',
},
])
.mockResolvedValueOnce([{ toolName: 'tool_a', workflowId: 'wf-1' }])
.mockResolvedValueOnce([{ isDeployed: true }])
hybridAuthMockFns.mockCheckHybridAuth.mockResolvedValueOnce({
success: true,
userId: 'user-1',
authType: 'api_key',
apiKeyType: 'personal',
})
mockGetUserEntityPermissions.mockResolvedValueOnce('write')
mockGenerateInternalToken.mockResolvedValueOnce('internal-token-user-1')
fetchMock.mockResolvedValueOnce(
new Response(JSON.stringify({ output: { ok: true } }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
})
)
const req = new NextRequest('http://localhost:3000/api/mcp/serve/server-1', {
method: 'POST',
headers: { 'X-API-Key': 'pk_test_123' },
body: JSON.stringify({
jsonrpc: '2.0',
id: 1,
method: 'tools/call',
params: { name: 'tool_a', arguments: { q: 'test' } },
}),
})
const response = await POST(req, { params: Promise.resolve({ serverId: 'server-1' }) })
expect(response.status).toBe(200)
expect(fetchMock).toHaveBeenCalledTimes(1)
const fetchOptions = fetchMock.mock.calls[0][1] as RequestInit
const headers = fetchOptions.headers as Record<string, string>
expect(headers.Authorization).toBe('Bearer internal-token-user-1')
expect(headers['X-Sim-MCP-Tool-Actor']).toBe('authenticated-user')
expect(headers['X-API-Key']).toBeUndefined()
expect(mockGenerateInternalToken).toHaveBeenCalledWith('user-1')
})
it('forwards internal token for private server session auth', async () => {
dbChainMockFns.limit
.mockResolvedValueOnce([
{
id: 'server-1',
name: 'Private Server',
workspaceId: 'ws-1',
isPublic: false,
createdBy: 'owner-1',
},
])
.mockResolvedValueOnce([{ toolName: 'tool_a', workflowId: 'wf-1' }])
.mockResolvedValueOnce([{ isDeployed: true }])
hybridAuthMockFns.mockCheckHybridAuth.mockResolvedValueOnce({
success: true,
userId: 'user-1',
authType: 'session',
})
mockGetUserEntityPermissions.mockResolvedValueOnce('read')
mockGenerateInternalToken.mockResolvedValueOnce('internal-token-user-1')
fetchMock.mockResolvedValueOnce(
new Response(JSON.stringify({ output: { ok: true } }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
})
)
const req = new NextRequest('http://localhost:3000/api/mcp/serve/server-1', {
method: 'POST',
body: JSON.stringify({
jsonrpc: '2.0',
id: 1,
method: 'tools/call',
params: { name: 'tool_a' },
}),
})
const response = await POST(req, { params: Promise.resolve({ serverId: 'server-1' }) })
expect(response.status).toBe(200)
expect(fetchMock).toHaveBeenCalledTimes(1)
const fetchOptions = fetchMock.mock.calls[0][1] as RequestInit
const headers = fetchOptions.headers as Record<string, string>
expect(headers.Authorization).toBe('Bearer internal-token-user-1')
expect(headers['X-Sim-MCP-Tool-Actor']).toBeUndefined()
expect(headers['X-API-Key']).toBeUndefined()
expect(mockGenerateInternalToken).toHaveBeenCalledWith('user-1')
})
it('rejects oversized MCP request bodies before parsing JSON', async () => {
dbChainMockFns.limit.mockResolvedValueOnce([
{
id: 'server-1',
name: 'Public Server',
workspaceId: 'ws-1',
isPublic: true,
createdBy: 'owner-1',
},
])
const req = new NextRequest('http://localhost:3000/api/mcp/serve/server-1', {
method: 'POST',
headers: { 'content-length': String(MCP_BYTE_LIMIT + 1) },
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'ping' }),
})
const response = await POST(req, { params: Promise.resolve({ serverId: 'server-1' }) })
const body = await response.json()
expect(response.status).toBe(413)
expect(body.error.message).toContain('MCP request body exceeds maximum size')
expect(fetchMock).not.toHaveBeenCalled()
})
it('rejects streamed MCP request bodies that exceed the cap without content-length', async () => {
const cancelSpy = vi.fn()
dbChainMockFns.limit.mockResolvedValueOnce([
{
id: 'server-1',
name: 'Public Server',
workspaceId: 'ws-1',
isPublic: true,
createdBy: 'owner-1',
},
])
const stream = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(new Uint8Array(MCP_BYTE_LIMIT))
controller.enqueue(new Uint8Array(1))
},
cancel: cancelSpy,
})
const request = new Request('http://localhost:3000/api/mcp/serve/server-1', {
method: 'POST',
body: stream,
duplex: 'half',
} as RequestInit & { duplex: 'half' })
const req = new NextRequest(request)
const response = await POST(req, { params: Promise.resolve({ serverId: 'server-1' }) })
const body = await response.json()
expect(response.status).toBe(413)
expect(body.error.message).toContain('MCP request body')
expect(cancelSpy).toHaveBeenCalled()
expect(fetchMock).not.toHaveBeenCalled()
})
it('rejects oversized tools/call arguments before internal fetch', async () => {
dbChainMockFns.limit.mockResolvedValueOnce([
{
id: 'server-1',
name: 'Public Server',
workspaceId: 'ws-1',
isPublic: true,
createdBy: 'owner-1',
},
])
const req = new NextRequest('http://localhost:3000/api/mcp/serve/server-1', {
method: 'POST',
body: JSON.stringify({
jsonrpc: '2.0',
id: 1,
method: 'tools/call',
params: { name: 'tool_a', arguments: { payload: 'x'.repeat(MCP_BYTE_LIMIT) } },
}),
})
const response = await POST(req, { params: Promise.resolve({ serverId: 'server-1' }) })
const body = await response.json()
expect(response.status).toBe(413)
expect(body.error.message).toContain('MCP request body')
expect(fetchMock).not.toHaveBeenCalled()
})
it('cancels and rejects oversized workflow execution responses', async () => {
const cancelSpy = vi.fn()
dbChainMockFns.limit
.mockResolvedValueOnce([
{
id: 'server-1',
name: 'Public Server',
workspaceId: 'ws-1',
isPublic: true,
createdBy: 'owner-1',
},
])
.mockResolvedValueOnce([{ toolName: 'tool_a', workflowId: 'wf-1' }])
.mockResolvedValueOnce([{ isDeployed: true }])
fetchMock.mockResolvedValueOnce(
new Response(
new ReadableStream<Uint8Array>({
cancel: cancelSpy,
}),
{
status: 200,
headers: { 'content-length': String(MCP_BYTE_LIMIT + 1) },
}
)
)
const req = new NextRequest('http://localhost:3000/api/mcp/serve/server-1', {
method: 'POST',
body: JSON.stringify({
jsonrpc: '2.0',
id: 1,
method: 'tools/call',
params: { name: 'tool_a', arguments: { q: 'test' } },
}),
})
const response = await POST(req, { params: Promise.resolve({ serverId: 'server-1' }) })
const body = await response.json()
expect(response.status).toBe(413)
expect(body.error.message).toContain('MCP workflow execution response')
expect(cancelSpy).toHaveBeenCalled()
})
it('cancels and rejects streamed workflow responses that exceed the cap', async () => {
const cancelSpy = vi.fn()
dbChainMockFns.limit
.mockResolvedValueOnce([
{
id: 'server-1',
name: 'Public Server',
workspaceId: 'ws-1',
isPublic: true,
createdBy: 'owner-1',
},
])
.mockResolvedValueOnce([{ toolName: 'tool_a', workflowId: 'wf-1' }])
.mockResolvedValueOnce([{ isDeployed: true }])
fetchMock.mockResolvedValueOnce(
new Response(
new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(new Uint8Array(MCP_BYTE_LIMIT))
controller.enqueue(new Uint8Array(1))
},
cancel: cancelSpy,
}),
{
status: 200,
headers: { 'content-length': '1' },
}
)
)
const req = new NextRequest('http://localhost:3000/api/mcp/serve/server-1', {
method: 'POST',
body: JSON.stringify({
jsonrpc: '2.0',
id: 1,
method: 'tools/call',
params: { name: 'tool_a', arguments: { q: 'test' } },
}),
})
const response = await POST(req, { params: Promise.resolve({ serverId: 'server-1' }) })
const body = await response.json()
expect(response.status).toBe(413)
expect(body.error.message).toContain('MCP workflow execution response')
expect(cancelSpy).toHaveBeenCalled()
})
it('preserves recoverable workflow execution statuses through the MCP bridge', async () => {
dbChainMockFns.limit
.mockResolvedValueOnce([
{
id: 'server-1',
name: 'Public Server',
workspaceId: 'ws-1',
isPublic: true,
createdBy: 'owner-1',
},
])
.mockResolvedValueOnce([{ toolName: 'tool_a', workflowId: 'wf-1' }])
.mockResolvedValueOnce([{ isDeployed: true }])
fetchMock.mockResolvedValueOnce(
new Response(
JSON.stringify({
success: false,
error: 'Workflow execution request body exceeds maximum size',
}),
{
status: 413,
headers: { 'Content-Type': 'application/json' },
}
)
)
const req = new NextRequest('http://localhost:3000/api/mcp/serve/server-1', {
method: 'POST',
body: JSON.stringify({
jsonrpc: '2.0',
id: 1,
method: 'tools/call',
params: { name: 'tool_a', arguments: { q: 'test' } },
}),
})
const response = await POST(req, { params: Promise.resolve({ serverId: 'server-1' }) })
const body = await response.json()
expect(response.status).toBe(413)
expect(body.error.code).toBe(-32600)
expect(body.error.data.httpStatus).toBe(413)
const fetchOptions = fetchMock.mock.calls[0][1] as RequestInit
const headers = fetchOptions.headers as Record<string, string>
expect(headers['X-Sim-MCP-Tool-Call']).toBe('true')
})
it('preserves upstream error status when workflow response is not JSON', async () => {
dbChainMockFns.limit
.mockResolvedValueOnce([
{
id: 'server-1',
name: 'Public Server',
workspaceId: 'ws-1',
isPublic: true,
createdBy: 'owner-1',
},
])
.mockResolvedValueOnce([{ toolName: 'tool_a', workflowId: 'wf-1' }])
.mockResolvedValueOnce([{ isDeployed: true }])
fetchMock.mockResolvedValueOnce(new Response('gateway timeout', { status: 408 }))
const req = new NextRequest('http://localhost:3000/api/mcp/serve/server-1', {
method: 'POST',
body: JSON.stringify({
jsonrpc: '2.0',
id: 1,
method: 'tools/call',
params: { name: 'tool_a', arguments: { q: 'test' } },
}),
})
const response = await POST(req, { params: Promise.resolve({ serverId: 'server-1' }) })
const body = await response.json()
expect(response.status).toBe(408)
expect(body.error.data.httpStatus).toBe(408)
expect(body.error.data.retryable).toBe(true)
})
it('preserves falsy workflow outputs in MCP tool results', async () => {
dbChainMockFns.limit
.mockResolvedValueOnce([
{
id: 'server-1',
name: 'Public Server',
workspaceId: 'ws-1',
isPublic: true,
createdBy: 'owner-1',
},
])
.mockResolvedValueOnce([{ toolName: 'tool_a', workflowId: 'wf-1' }])
.mockResolvedValueOnce([{ isDeployed: true }])
fetchMock.mockResolvedValueOnce(
new Response(JSON.stringify({ success: true, output: false }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
})
)
const req = new NextRequest('http://localhost:3000/api/mcp/serve/server-1', {
method: 'POST',
body: JSON.stringify({
jsonrpc: '2.0',
id: 1,
method: 'tools/call',
params: { name: 'tool_a', arguments: { q: 'test' } },
}),
})
const response = await POST(req, { params: Promise.resolve({ serverId: 'server-1' }) })
const body = await response.json()
expect(response.status).toBe(200)
expect(body.result.content[0].text).toBe('false')
})
it('serializes missing workflow output without failing the MCP tool call', async () => {
dbChainMockFns.limit
.mockResolvedValueOnce([
{
id: 'server-1',
name: 'Public Server',
workspaceId: 'ws-1',
isPublic: true,
createdBy: 'owner-1',
},
])
.mockResolvedValueOnce([{ toolName: 'tool_a', workflowId: 'wf-1' }])
.mockResolvedValueOnce([{ isDeployed: true }])
fetchMock.mockResolvedValueOnce(
new Response(JSON.stringify({ success: true }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
})
)
const req = new NextRequest('http://localhost:3000/api/mcp/serve/server-1', {
method: 'POST',
body: JSON.stringify({
jsonrpc: '2.0',
id: 1,
method: 'tools/call',
params: { name: 'tool_a', arguments: { q: 'test' } },
}),
})
const response = await POST(req, { params: Promise.resolve({ serverId: 'server-1' }) })
const body = await response.json()
expect(response.status).toBe(200)
expect(body.result.content[0].text).toContain('"success": true')
})
it('serializes non-object workflow JSON responses from response blocks', async () => {
dbChainMockFns.limit
.mockResolvedValueOnce([
{
id: 'server-1',
name: 'Private Server',
workspaceId: 'ws-1',
isPublic: false,
createdBy: 'owner-1',
},
])
.mockResolvedValueOnce([{ toolName: 'tool_a', workflowId: 'wf-1' }])
.mockResolvedValueOnce([{ isDeployed: true }])
hybridAuthMockFns.mockCheckHybridAuth.mockResolvedValueOnce({
success: true,
userId: 'user-1',
authType: 'api_key',
apiKeyType: 'personal',
})
mockGetUserEntityPermissions.mockResolvedValueOnce('write')
fetchMock.mockResolvedValueOnce(
new Response(JSON.stringify(['a', 'b']), {
status: 200,
headers: { 'Content-Type': 'application/json' },
})
)
const req = new NextRequest('http://localhost:3000/api/mcp/serve/server-1', {
method: 'POST',
headers: { 'X-API-Key': 'pk_test_123' },
body: JSON.stringify({
jsonrpc: '2.0',
id: 1,
method: 'tools/call',
params: { name: 'tool_a', arguments: { q: 'test' } },
}),
})
const response = await POST(req, { params: Promise.resolve({ serverId: 'server-1' }) })
const body = await response.json()
expect(response.status).toBe(200)
expect(body.result.content[0].text).toBe(JSON.stringify(['a', 'b'], null, 2))
})
it('rejects duplicate tool names instead of choosing an arbitrary workflow', async () => {
dbChainMockFns.limit
.mockResolvedValueOnce([
{
id: 'server-1',
name: 'Public Server',
workspaceId: 'ws-1',
isPublic: true,
createdBy: 'owner-1',
},
])
.mockResolvedValueOnce([
{ toolName: 'tool_a', workflowId: 'wf-1' },
{ toolName: 'tool_a', workflowId: 'wf-2' },
])
const req = new NextRequest('http://localhost:3000/api/mcp/serve/server-1', {
method: 'POST',
body: JSON.stringify({
jsonrpc: '2.0',
id: 1,
method: 'tools/call',
params: { name: 'tool_a', arguments: { q: 'test' } },
}),
})
const response = await POST(req, { params: Promise.resolve({ serverId: 'server-1' }) })
const body = await response.json()
expect(response.status).toBe(409)
expect(body.error.data.code).toBe('duplicate_tool_name')
expect(fetchMock).not.toHaveBeenCalled()
})
it('aborts the internal workflow fetch when the MCP client disconnects', async () => {
const requestAbortController = new AbortController()
dbChainMockFns.limit
.mockResolvedValueOnce([
{
id: 'server-1',
name: 'Public Server',
workspaceId: 'ws-1',
isPublic: true,
createdBy: 'owner-1',
},
])
.mockResolvedValueOnce([{ toolName: 'tool_a', workflowId: 'wf-1' }])
.mockResolvedValueOnce([{ isDeployed: true }])
fetchMock.mockImplementationOnce((_url, init: RequestInit) => {
const signal = init.signal as AbortSignal
return new Promise<Response>((_resolve, reject) => {
signal.addEventListener(
'abort',
() => {
reject(Object.assign(new Error('aborted'), { name: 'AbortError' }))
},
{ once: true }
)
requestAbortController.abort()
})
})
const req = new NextRequest(
new Request('http://localhost:3000/api/mcp/serve/server-1', {
method: 'POST',
body: JSON.stringify({
jsonrpc: '2.0',
id: 1,
method: 'tools/call',
params: { name: 'tool_a', arguments: { q: 'test' } },
}),
signal: requestAbortController.signal,
})
)
const response = await POST(req, { params: Promise.resolve({ serverId: 'server-1' }) })
expect(response.status).toBe(499)
})
it('paginates tools/list by tool count', async () => {
const pageRows = Array.from({ length: MCP_TOOLS_LIST_LIMIT + 1 }, (_, index) => ({
id: `tool-id-${String(index).padStart(3, '0')}`,
toolNameBytes: 10 + index,
toolDescriptionBytes: 0,
parameterSchemaBytes: 32,
}))
dbChainMockFns.limit
.mockResolvedValueOnce([
{
id: 'server-1',
name: 'Public Server',
workspaceId: 'ws-1',
isPublic: true,
createdBy: 'owner-1',
},
])
.mockResolvedValueOnce([])
.mockResolvedValueOnce(pageRows)
.mockResolvedValueOnce(
pageRows.map((row, index) => ({
id: row.id,
toolName: `tool_${index}`,
toolDescription: null,
parameterSchema: { type: 'object', properties: {} },
}))
)
const req = new NextRequest('http://localhost:3000/api/mcp/serve/server-1', {
method: 'POST',
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/list' }),
})
const response = await POST(req, { params: Promise.resolve({ serverId: 'server-1' }) })
const body = await response.json()
expect(response.status).toBe(200)
expect(body.result.tools).toHaveLength(MCP_TOOLS_LIST_LIMIT)
expect(body.result.nextCursor).toBe('tool-id-099')
})
it('bounds tools/list by stored metadata estimate', async () => {
dbChainMockFns.limit
.mockResolvedValueOnce([
{
id: 'server-1',
name: 'Public Server',
workspaceId: 'ws-1',
isPublic: true,
createdBy: 'owner-1',
},
])
.mockResolvedValueOnce([])
.mockResolvedValueOnce([
{
id: 'tool-id-1',
toolNameBytes: 6,
toolDescriptionBytes: MCP_BYTE_LIMIT + 1,
parameterSchemaBytes: 32,
},
])
const req = new NextRequest('http://localhost:3000/api/mcp/serve/server-1', {
method: 'POST',
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/list' }),
})
const response = await POST(req, { params: Promise.resolve({ serverId: 'server-1' }) })
const body = await response.json()
expect(response.status).toBe(413)
expect(body.error.message).toContain('tools/list response is too large')
})
it('bounds tools/list by final serialized response size', async () => {
dbChainMockFns.limit
.mockResolvedValueOnce([
{
id: 'server-1',
name: 'Public Server',
workspaceId: 'ws-1',
isPublic: true,
createdBy: 'owner-1',
},
])
.mockResolvedValueOnce([])
.mockResolvedValueOnce([
{
id: 'tool-id-1',
toolNameBytes: 6,
toolDescriptionBytes: 1,
parameterSchemaBytes: 32,
},
])
.mockResolvedValueOnce([
{
id: 'tool-id-1',
toolName: 'tool_a',
toolDescription: 'x'.repeat(MCP_BYTE_LIMIT),
parameterSchema: { type: 'object', properties: {} },
},
])
const req = new NextRequest('http://localhost:3000/api/mcp/serve/server-1', {
method: 'POST',
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/list' }),
})
const response = await POST(req, { params: Promise.resolve({ serverId: 'server-1' }) })
const body = await response.json()
expect(response.status).toBe(413)
expect(body.error.message).toContain('tools/list response is too large')
})
describe('initialize protocol version negotiation', () => {
async function callInitialize(protocolVersion?: string) {
dbChainMockFns.limit.mockResolvedValueOnce([
{
id: 'server-1',
name: 'Public Server',
workspaceId: 'ws-1',
isPublic: true,
createdBy: 'owner-1',
},
])
const params: Record<string, unknown> = {
capabilities: {},
clientInfo: { name: 'test', version: '1.0.0' },
}
if (protocolVersion !== undefined) params.protocolVersion = protocolVersion
const req = new NextRequest('http://localhost:3000/api/mcp/serve/server-1', {
method: 'POST',
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params }),
})
const res = await POST(req, { params: Promise.resolve({ serverId: 'server-1' }) })
return res.json() as Promise<{ result: { protocolVersion: string } }>
}
it('echoes a supported client protocolVersion (2025-06-18)', async () => {
const body = await callInitialize('2025-06-18')
expect(body.result.protocolVersion).toBe('2025-06-18')
})
it('echoes a supported client protocolVersion (2024-11-05)', async () => {
const body = await callInitialize('2024-11-05')
expect(body.result.protocolVersion).toBe('2024-11-05')
})
it('falls back to SDK latest when client requests unknown version', async () => {
const { LATEST_PROTOCOL_VERSION } = await import('@modelcontextprotocol/sdk/types.js')
const body = await callInitialize('2099-01-01')
expect(body.result.protocolVersion).toBe(LATEST_PROTOCOL_VERSION)
})
it('falls back to SDK latest when client omits protocolVersion', async () => {
const { LATEST_PROTOCOL_VERSION } = await import('@modelcontextprotocol/sdk/types.js')
const body = await callInitialize(undefined)
expect(body.result.protocolVersion).toBe(LATEST_PROTOCOL_VERSION)
})
})
})
@@ -0,0 +1,912 @@
/**
* MCP Serve Endpoint - Implements MCP protocol for workflow servers using SDK types.
*/
import {
type CallToolResult,
ErrorCode,
type InitializeResult,
isJSONRPCNotification,
isJSONRPCRequest,
type JSONRPCError,
type JSONRPCMessage,
type JSONRPCResultResponse,
LATEST_PROTOCOL_VERSION,
type ListToolsResult,
type RequestId,
SUPPORTED_PROTOCOL_VERSIONS,
type Tool,
} from '@modelcontextprotocol/sdk/types.js'
import { db } from '@sim/db'
import { workflow, workflowMcpServer, workflowMcpTool, workspace } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, asc, eq, gt, isNull, sql } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import {
mcpJsonRpcNotificationSchema,
mcpJsonRpcRequestSchema,
mcpServeRouteParamsSchema,
mcpToolCallParamsSchema,
} from '@/lib/api/contracts/mcp'
import { AuthType, checkHybridAuth } from '@/lib/auth/hybrid'
import { generateInternalToken } from '@/lib/auth/internal'
import {
API_EXECUTION_REQUIRES_PAID_PLAN_MESSAGE,
isWorkspaceApiExecutionEntitled,
} from '@/lib/billing/core/api-access'
import { getMaxExecutionTimeout } from '@/lib/core/execution-limits'
import {
assertContentLengthWithinLimit,
assertKnownSizeWithinLimit,
isPayloadSizeLimitError,
readResponseTextWithLimit,
readStreamToBufferWithLimit,
} from '@/lib/core/utils/stream-limits'
import { getInternalApiBaseUrl } from '@/lib/core/utils/urls'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { SIM_VIA_HEADER } from '@/lib/execution/call-chain'
import {
MAX_MCP_PARAMETER_SCHEMA_BYTES,
MAX_MCP_TOOLS_LIST_RESPONSE_BYTES,
MAX_MCP_TOOLS_PER_SERVER,
MAX_MCP_WORKFLOW_RESPONSE_BYTES,
MCP_TOOL_BRIDGE_ACTOR_HEADER,
MCP_TOOL_BRIDGE_HEADER,
} from '@/lib/mcp/constants'
import { getMeaningfulWorkflowDescription } from '@/lib/mcp/workflow-tool-schema'
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
const logger = createLogger('WorkflowMcpServeAPI')
const MAX_MCP_SERVE_BODY_BYTES = 10 * 1024 * 1024
const MAX_MCP_WORKFLOW_REQUEST_BYTES = 10 * 1024 * 1024
const MAX_MCP_TOOL_RESULT_TEXT_BYTES = 10 * 1024 * 1024
const MAX_MCP_TOOLS_LIST_COUNT = MAX_MCP_TOOLS_PER_SERVER
const MAX_MCP_TOOLS_LIST_SCHEMA_BYTES = MAX_MCP_PARAMETER_SCHEMA_BYTES
const MB = 1024 * 1024
function negotiateProtocolVersion(rpcParams: unknown): string {
const requested =
rpcParams && typeof rpcParams === 'object' && 'protocolVersion' in rpcParams
? (rpcParams as { protocolVersion?: unknown }).protocolVersion
: undefined
if (typeof requested === 'string' && SUPPORTED_PROTOCOL_VERSIONS.includes(requested)) {
return requested
}
return LATEST_PROTOCOL_VERSION
}
export const dynamic = 'force-dynamic'
interface RouteParams {
serverId: string
}
interface ExecuteAuthContext {
userId: string
useAuthenticatedUserAsActor: boolean
}
function createResponse(id: RequestId, result: unknown): JSONRPCResultResponse {
return {
jsonrpc: '2.0',
id,
result: result as JSONRPCResultResponse['result'],
}
}
function createError(
id: RequestId,
code: ErrorCode | number,
message: string,
data?: unknown
): JSONRPCError {
return {
jsonrpc: '2.0',
id,
error: { code, message, ...(data !== undefined && { data }) },
}
}
function clientCancelledJsonRpcResponse(id: RequestId): NextResponse {
return NextResponse.json(
createError(id, ErrorCode.ConnectionClosed, 'Client cancelled request'),
{
status: 499,
}
)
}
function callerAbortedJsonRpcResponse(
id: RequestId,
abortSignal?: ManagedAbortSignal | null
): NextResponse | null {
return abortSignal?.isCallerAborted() ? clientCancelledJsonRpcResponse(id) : null
}
function limitMessage(label: string, maxBytes: number): string {
return `${label} exceeds maximum size of ${Math.round(maxBytes / MB)}MB`
}
async function readJsonRpcBody(request: NextRequest): Promise<unknown> {
assertContentLengthWithinLimit(request.headers, MAX_MCP_SERVE_BODY_BYTES, 'MCP request body')
const buffer = await readStreamToBufferWithLimit(request.body, {
maxBytes: MAX_MCP_SERVE_BODY_BYTES,
label: 'MCP request body',
signal: request.signal,
})
return JSON.parse(buffer.toString('utf-8'))
}
interface ManagedAbortSignal {
signal: AbortSignal
cleanup: () => void
isCallerAborted: () => boolean
isTimedOut: () => boolean
}
function createManagedAbortSignal(
parentSignal: AbortSignal,
timeoutMs: number
): ManagedAbortSignal {
const controller = new AbortController()
let callerAborted = false
let timedOut = false
const timeoutId = setTimeout(() => {
timedOut = true
controller.abort(new Error(`MCP workflow execution timed out after ${timeoutMs}ms`))
}, timeoutMs)
const abortFromParent = () => {
callerAborted = true
controller.abort(parentSignal.reason ?? new Error('MCP client disconnected'))
}
if (parentSignal.aborted) {
abortFromParent()
} else {
parentSignal.addEventListener('abort', abortFromParent, { once: true })
}
return {
signal: controller.signal,
cleanup: () => {
clearTimeout(timeoutId)
parentSignal.removeEventListener('abort', abortFromParent)
},
isCallerAborted: () => callerAborted || parentSignal.aborted,
isTimedOut: () => timedOut,
}
}
function serializeToolText(value: unknown): string {
const text = JSON.stringify(value, null, 2) ?? 'null'
assertKnownSizeWithinLimit(
Buffer.byteLength(text, 'utf-8'),
MAX_MCP_TOOL_RESULT_TEXT_BYTES,
'MCP tool result text'
)
return text
}
function createJsonRpcResponseWithLimit(
id: RequestId,
result: unknown,
maxBytes: number,
label: string
): NextResponse {
const responseBody = createResponse(id, result)
const responseText = JSON.stringify(responseBody)
assertKnownSizeWithinLimit(Buffer.byteLength(responseText, 'utf-8'), maxBytes, label)
return new NextResponse(responseText, {
status: 200,
headers: { 'Content-Type': 'application/json' },
})
}
function toToolInputSchema(schema: unknown): Partial<Tool['inputSchema']> {
if (!schema || typeof schema !== 'object' || Array.isArray(schema)) return {}
const candidate = schema as Record<string, unknown>
const properties =
candidate.properties &&
typeof candidate.properties === 'object' &&
!Array.isArray(candidate.properties)
? (candidate.properties as Tool['inputSchema']['properties'])
: {}
const required = Array.isArray(candidate.required)
? candidate.required.filter((entry): entry is string => typeof entry === 'string')
: undefined
return {
properties,
...(required && required.length > 0 && { required }),
}
}
function isJsonObject(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === 'object' && !Array.isArray(value)
}
function parseJsonValue(text: string): { success: true; value: unknown } | { success: false } {
if (!text) return { success: true, value: {} }
try {
return { success: true, value: JSON.parse(text) }
} catch {
return { success: false }
}
}
function hasResponseField(value: Record<string, unknown>, property: string): boolean {
return Object.hasOwn(value, property)
}
function getWorkflowErrorStatus(status: number): number {
return [400, 401, 403, 404, 408, 409, 413, 429, 499, 503].includes(status) ? status : 500
}
function getWorkflowErrorCode(status: number, executeResult: Record<string, unknown>): ErrorCode {
if (status === 499) return ErrorCode.ConnectionClosed
if (status === 400) return ErrorCode.InvalidParams
if (status === 413 && executeResult.code !== 'workflow_response_too_large') {
return ErrorCode.InvalidRequest
}
return ErrorCode.InternalError
}
function getToolsListCursor(rpcParams: unknown): string | undefined {
if (!rpcParams || typeof rpcParams !== 'object' || !('cursor' in rpcParams)) return undefined
const cursor = (rpcParams as { cursor?: unknown }).cursor
return typeof cursor === 'string' && cursor.length > 0 ? cursor : undefined
}
async function getDuplicateToolName(serverId: string): Promise<string | null> {
const [duplicate] = await db
.select({ toolName: workflowMcpTool.toolName })
.from(workflowMcpTool)
.where(and(eq(workflowMcpTool.serverId, serverId), isNull(workflowMcpTool.archivedAt)))
.groupBy(workflowMcpTool.toolName)
.having(sql`count(*) > 1`)
.limit(1)
return duplicate?.toolName ?? null
}
async function readWorkflowExecutionResult(
response: Response,
signal: AbortSignal
): Promise<unknown> {
const text = await readResponseTextWithLimit(response, {
maxBytes: MAX_MCP_WORKFLOW_RESPONSE_BYTES,
label: 'MCP workflow execution response',
signal,
})
const parsed = parseJsonValue(text)
if (parsed.success) return parsed.value
if (!response.ok) return { error: response.statusText || 'Workflow execution failed' }
throw new Error('Invalid workflow execution response')
}
async function getServer(serverId: string) {
const [server] = await db
.select({
id: workflowMcpServer.id,
name: workflowMcpServer.name,
workspaceId: workflowMcpServer.workspaceId,
isPublic: workflowMcpServer.isPublic,
createdBy: workflowMcpServer.createdBy,
})
.from(workflowMcpServer)
.innerJoin(workspace, eq(workflowMcpServer.workspaceId, workspace.id))
.where(
and(
eq(workflowMcpServer.id, serverId),
isNull(workflowMcpServer.deletedAt),
isNull(workspace.archivedAt)
)
)
.limit(1)
return server
}
type WorkflowMcpServeServer = NonNullable<Awaited<ReturnType<typeof getServer>>>
async function authorizeMcpServeRequest(
request: NextRequest,
server: WorkflowMcpServeServer,
options: { requireAuthForPublic?: boolean } = {}
): Promise<{ response?: NextResponse; executeAuthContext?: ExecuteAuthContext }> {
if (!(await isWorkspaceApiExecutionEntitled(server.workspaceId))) {
return {
response: NextResponse.json(
{ error: API_EXECUTION_REQUIRES_PAID_PLAN_MESSAGE },
{ status: 402 }
),
}
}
if (server.isPublic && !options.requireAuthForPublic) return {}
const auth = await checkHybridAuth(request, { requireWorkflowId: false })
if (!auth.success || !auth.userId) {
return { response: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) }
}
if (server.isPublic) return {}
if (auth.apiKeyType === 'workspace' && auth.workspaceId !== server.workspaceId) {
return { response: NextResponse.json({ error: 'Forbidden' }, { status: 403 }) }
}
const workspacePermission = await getUserEntityPermissions(
auth.userId,
'workspace',
server.workspaceId
)
if (workspacePermission === null) {
return { response: NextResponse.json({ error: 'Forbidden' }, { status: 403 }) }
}
return {
executeAuthContext: {
userId: auth.userId,
useAuthenticatedUserAsActor:
auth.authType === AuthType.API_KEY && auth.apiKeyType === 'personal',
},
}
}
function unsupportedSseTransportResponse(): NextResponse {
return NextResponse.json(
{
error: {
code: 'unsupported_transport',
message: 'SSE transport is not supported for workflow MCP servers',
supportedTransports: ['streamable-http'],
allowedMethods: ['GET', 'POST', 'DELETE'],
},
},
{
status: 405,
headers: {
Allow: 'GET, POST, DELETE',
'X-MCP-Supported-Transport': 'streamable-http',
},
}
)
}
export const GET = withRouteHandler(
async (request: NextRequest, { params }: { params: Promise<RouteParams> }) => {
try {
const { serverId } = mcpServeRouteParamsSchema.parse(await params)
const server = await getServer(serverId)
if (!server) {
return NextResponse.json({ error: 'Server not found' }, { status: 404 })
}
const authResult = await authorizeMcpServeRequest(request, server)
if (authResult.response) return authResult.response
if (request.headers.get('accept')?.includes('text/event-stream')) {
return unsupportedSseTransportResponse()
}
return NextResponse.json({
name: server.name,
version: '1.0.0',
protocolVersion: '2024-11-05',
capabilities: { tools: {} },
})
} catch (error) {
logger.error('Error getting MCP server info:', error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}
)
export const POST = withRouteHandler(
async (request: NextRequest, { params }: { params: Promise<RouteParams> }) => {
try {
const { serverId } = mcpServeRouteParamsSchema.parse(await params)
const server = await getServer(serverId)
if (!server) {
return NextResponse.json({ error: 'Server not found' }, { status: 404 })
}
let executeAuthContext: ExecuteAuthContext | null = null
const authResult = await authorizeMcpServeRequest(request, server)
if (authResult.response) return authResult.response
executeAuthContext = authResult.executeAuthContext ?? null
let body: unknown
try {
body = await readJsonRpcBody(request)
} catch (error) {
if (isPayloadSizeLimitError(error)) {
logger.warn('MCP request body exceeded size limit', {
maxBytes: error.maxBytes,
observedBytes: error.observedBytes,
})
return NextResponse.json(
createError(
0,
ErrorCode.InvalidRequest,
limitMessage('MCP request body', MAX_MCP_SERVE_BODY_BYTES)
),
{ status: 413 }
)
}
if (request.signal.aborted) return clientCancelledJsonRpcResponse(0)
return NextResponse.json(createError(0, ErrorCode.ParseError, 'Invalid JSON body'), {
status: 400,
})
}
const message = body as JSONRPCMessage
if (isJSONRPCNotification(message)) {
const notificationValidation = mcpJsonRpcNotificationSchema.safeParse(message)
if (!notificationValidation.success) {
return NextResponse.json(
createError(0, ErrorCode.InvalidRequest, 'Invalid JSON-RPC message'),
{
status: 400,
}
)
}
logger.info(`Received notification: ${message.method}`)
return new NextResponse(null, { status: 202 })
}
if (!isJSONRPCRequest(message)) {
return NextResponse.json(
createError(0, ErrorCode.InvalidRequest, 'Invalid JSON-RPC message'),
{
status: 400,
}
)
}
const requestValidation = mcpJsonRpcRequestSchema.safeParse(message)
if (!requestValidation.success) {
return NextResponse.json(
createError(0, ErrorCode.InvalidRequest, 'Invalid JSON-RPC message'),
{
status: 400,
}
)
}
const { id, method, params: rpcParams } = requestValidation.data
switch (method) {
case 'initialize': {
const result: InitializeResult = {
protocolVersion: negotiateProtocolVersion(rpcParams),
capabilities: { tools: {} },
serverInfo: { name: server.name, version: '1.0.0' },
}
return NextResponse.json(createResponse(id, result))
}
case 'ping':
return NextResponse.json(createResponse(id, {}))
case 'tools/list':
return handleToolsList(id, serverId, rpcParams)
case 'tools/call': {
const paramsValidation = mcpToolCallParamsSchema.safeParse(rpcParams)
if (!paramsValidation.success) {
return NextResponse.json(
createError(id, ErrorCode.InvalidParams, 'Invalid tool call parameters'),
{
status: 400,
}
)
}
return handleToolsCall(
id,
serverId,
paramsValidation.data,
executeAuthContext,
server.isPublic ? server.createdBy : undefined,
request.headers.get(SIM_VIA_HEADER),
request.signal
)
}
default:
return NextResponse.json(
createError(id, ErrorCode.MethodNotFound, `Method not found: ${method}`),
{
status: 404,
}
)
}
} catch (error) {
logger.error('Error handling MCP request:', error)
return NextResponse.json(createError(0, ErrorCode.InternalError, 'Internal error'), {
status: 500,
})
}
}
)
async function handleToolsList(
id: RequestId,
serverId: string,
rpcParams: unknown
): Promise<NextResponse> {
try {
const duplicateToolName = await getDuplicateToolName(serverId)
if (duplicateToolName) {
return NextResponse.json(
createError(id, ErrorCode.InvalidRequest, 'MCP server has duplicate tool names', {
code: 'duplicate_tool_name',
toolName: duplicateToolName,
recovery: 'Rename or remove duplicate workflow MCP tools before listing this server',
}),
{ status: 409 }
)
}
const cursor = getToolsListCursor(rpcParams)
const pageCondition = cursor ? gt(workflowMcpTool.id, cursor) : undefined
const toolSizes = await db
.select({
id: workflowMcpTool.id,
toolNameBytes: sql<number>`octet_length(${workflowMcpTool.toolName})`,
toolDescriptionBytes: sql<number>`coalesce(octet_length(${workflowMcpTool.toolDescription}), 0)`,
parameterSchemaBytes: sql<number>`octet_length(${workflowMcpTool.parameterSchema}::text)`,
})
.from(workflowMcpTool)
.where(
and(
eq(workflowMcpTool.serverId, serverId),
isNull(workflowMcpTool.archivedAt),
pageCondition
)
)
.orderBy(asc(workflowMcpTool.id))
.limit(MAX_MCP_TOOLS_LIST_COUNT + 1)
const pageSizes = toolSizes.slice(0, MAX_MCP_TOOLS_LIST_COUNT)
let estimatedSchemaBytes = 0
let estimatedMetadataBytes = 0
for (const toolSize of pageSizes) {
estimatedSchemaBytes += Number(toolSize.parameterSchemaBytes) || 0
estimatedMetadataBytes +=
(Number(toolSize.toolNameBytes) || 0) +
(Number(toolSize.toolDescriptionBytes) || 0) +
(Number(toolSize.parameterSchemaBytes) || 0)
assertKnownSizeWithinLimit(
estimatedSchemaBytes,
MAX_MCP_TOOLS_LIST_SCHEMA_BYTES,
'MCP tools/list schemas'
)
assertKnownSizeWithinLimit(
estimatedMetadataBytes,
MAX_MCP_TOOLS_LIST_RESPONSE_BYTES,
'MCP tools/list stored metadata'
)
}
const tools = await db
.select({
id: workflowMcpTool.id,
toolName: workflowMcpTool.toolName,
toolDescription: workflowMcpTool.toolDescription,
parameterSchema: workflowMcpTool.parameterSchema,
workflowName: workflow.name,
workflowDescription: workflow.description,
})
.from(workflowMcpTool)
.innerJoin(workflow, eq(workflowMcpTool.workflowId, workflow.id))
.where(
and(
eq(workflowMcpTool.serverId, serverId),
isNull(workflowMcpTool.archivedAt),
pageCondition
)
)
.orderBy(asc(workflowMcpTool.id))
.limit(MAX_MCP_TOOLS_LIST_COUNT + 1)
const hasNextPage = tools.length > MAX_MCP_TOOLS_LIST_COUNT
const pageTools = tools.slice(0, MAX_MCP_TOOLS_LIST_COUNT)
const nextCursor = hasNextPage ? pageTools.at(-1)?.id : undefined
let schemaBytes = 0
const result: ListToolsResult = {
tools: pageTools.map((tool) => {
const schema = toToolInputSchema(tool.parameterSchema)
const schemaByteLength = Buffer.byteLength(JSON.stringify(schema ?? {}), 'utf-8')
schemaBytes += schemaByteLength
assertKnownSizeWithinLimit(
schemaBytes,
MAX_MCP_TOOLS_LIST_SCHEMA_BYTES,
'MCP tools/list schemas'
)
return {
name: tool.toolName,
description:
tool.toolDescription?.trim() ||
getMeaningfulWorkflowDescription(tool.workflowDescription, tool.workflowName) ||
`Execute workflow: ${tool.toolName}`,
inputSchema: {
type: 'object' as const,
properties: schema?.properties || {},
...(schema?.required && schema.required.length > 0 && { required: schema.required }),
},
}
}),
...(nextCursor && { nextCursor }),
}
return createJsonRpcResponseWithLimit(
id,
result,
MAX_MCP_TOOLS_LIST_RESPONSE_BYTES,
'MCP tools/list response'
)
} catch (error) {
if (isPayloadSizeLimitError(error)) {
logger.warn('MCP tools/list exceeded size limit', {
serverId,
maxBytes: error.maxBytes,
observedBytes: error.observedBytes,
})
return NextResponse.json(
createError(id, ErrorCode.InternalError, 'MCP tools/list response is too large', {
code: 'payload_too_large',
maxBytes: error.maxBytes,
observedBytes: error.observedBytes,
recovery: 'Reduce tool names, descriptions, schemas, or tool count before retrying',
}),
{ status: 413 }
)
}
logger.error('Error listing tools:', error)
return NextResponse.json(createError(id, ErrorCode.InternalError, 'Failed to list tools'), {
status: 500,
})
}
}
async function handleToolsCall(
id: RequestId,
serverId: string,
params: { name: string; arguments?: Record<string, unknown> } | undefined,
executeAuthContext?: ExecuteAuthContext | null,
publicServerOwnerId?: string,
simViaHeader?: string | null,
requestSignal?: AbortSignal
): Promise<NextResponse> {
let abortSignal: ManagedAbortSignal | null = null
try {
if (!params?.name) {
return NextResponse.json(createError(id, ErrorCode.InvalidParams, 'Tool name required'), {
status: 400,
})
}
abortSignal = createManagedAbortSignal(
requestSignal ?? new AbortController().signal,
getMaxExecutionTimeout()
)
const abortedBeforeToolLookup = callerAbortedJsonRpcResponse(id, abortSignal)
if (abortedBeforeToolLookup) return abortedBeforeToolLookup
const matchingTools = await db
.select({
toolName: workflowMcpTool.toolName,
workflowId: workflowMcpTool.workflowId,
})
.from(workflowMcpTool)
.where(
and(
eq(workflowMcpTool.serverId, serverId),
eq(workflowMcpTool.toolName, params.name),
isNull(workflowMcpTool.archivedAt)
)
)
.orderBy(asc(workflowMcpTool.id))
.limit(2)
const abortedAfterToolLookup = callerAbortedJsonRpcResponse(id, abortSignal)
if (abortedAfterToolLookup) return abortedAfterToolLookup
if (matchingTools.length > 1) {
return NextResponse.json(
createError(id, ErrorCode.InvalidRequest, `Duplicate tool name: ${params.name}`, {
code: 'duplicate_tool_name',
toolName: params.name,
recovery: 'Rename or remove duplicate workflow MCP tools before calling this tool',
}),
{ status: 409 }
)
}
const [tool] = matchingTools
if (!tool) {
return NextResponse.json(
createError(id, ErrorCode.InvalidParams, `Tool not found: ${params.name}`),
{
status: 404,
}
)
}
const [wf] = await db
.select({ isDeployed: workflow.isDeployed })
.from(workflow)
.where(and(eq(workflow.id, tool.workflowId), isNull(workflow.archivedAt)))
.limit(1)
const abortedAfterWorkflowLookup = callerAbortedJsonRpcResponse(id, abortSignal)
if (abortedAfterWorkflowLookup) return abortedAfterWorkflowLookup
if (!wf?.isDeployed) {
return NextResponse.json(
createError(id, ErrorCode.InternalError, 'Workflow is not deployed'),
{
status: 400,
}
)
}
const executeUrl = `${getInternalApiBaseUrl()}/api/workflows/${tool.workflowId}/execute`
const headers: Record<string, string> = {
'Content-Type': 'application/json',
[MCP_TOOL_BRIDGE_HEADER]: 'true',
}
const abortedBeforeExecute = callerAbortedJsonRpcResponse(id, abortSignal)
if (abortedBeforeExecute) return abortedBeforeExecute
if (publicServerOwnerId) {
const internalToken = await generateInternalToken(publicServerOwnerId)
headers.Authorization = `Bearer ${internalToken}`
} else if (executeAuthContext) {
const internalToken = await generateInternalToken(executeAuthContext.userId)
headers.Authorization = `Bearer ${internalToken}`
if (executeAuthContext.useAuthenticatedUserAsActor) {
headers[MCP_TOOL_BRIDGE_ACTOR_HEADER] = 'authenticated-user'
}
}
if (simViaHeader) {
headers[SIM_VIA_HEADER] = simViaHeader
}
logger.info(`Executing workflow ${tool.workflowId} via MCP tool ${params.name}`)
const workflowRequestBody = JSON.stringify({
input: params.arguments || {},
triggerType: 'mcp',
includeFileBase64: false,
})
assertKnownSizeWithinLimit(
Buffer.byteLength(workflowRequestBody, 'utf-8'),
MAX_MCP_WORKFLOW_REQUEST_BYTES,
'MCP workflow execution request body'
)
const response = await fetch(executeUrl, {
method: 'POST',
headers,
body: workflowRequestBody,
signal: abortSignal.signal,
})
const executeResult = await readWorkflowExecutionResult(response, abortSignal.signal)
const executeResultObject = isJsonObject(executeResult) ? executeResult : null
if (!response.ok) {
const errorMessage =
typeof executeResultObject?.error === 'string'
? executeResultObject.error
: 'Workflow execution failed'
const status = getWorkflowErrorStatus(response.status)
const responseHeaders: Record<string, string> = {}
const retryAfter = response.headers.get('retry-after')
if (retryAfter) responseHeaders['Retry-After'] = retryAfter
return NextResponse.json(
createError(
id,
getWorkflowErrorCode(response.status, executeResultObject ?? {}),
errorMessage,
{
httpStatus: response.status,
retryable: [408, 429, 503].includes(response.status),
code:
typeof executeResultObject?.code === 'string' ? executeResultObject.code : undefined,
}
),
{ status, headers: responseHeaders }
)
}
const toolOutput =
executeResultObject?.success === false
? executeResult
: executeResultObject && hasResponseField(executeResultObject, 'output')
? executeResultObject.output
: executeResult
const result: CallToolResult = {
content: [{ type: 'text', text: serializeToolText(toolOutput) }],
isError: executeResultObject?.success === false,
}
return createJsonRpcResponseWithLimit(
id,
result,
MAX_MCP_WORKFLOW_RESPONSE_BYTES,
'MCP tool call response'
)
} catch (error) {
if (abortSignal?.isTimedOut()) {
return NextResponse.json(
createError(id, ErrorCode.InternalError, 'Tool execution timed out', {
code: 'timeout',
retryable: true,
}),
{
status: 408,
}
)
}
const abortedAfterExecute = callerAbortedJsonRpcResponse(id, abortSignal)
if (abortedAfterExecute) return abortedAfterExecute
if (isPayloadSizeLimitError(error)) {
logger.warn('MCP tool call exceeded size limit', {
maxBytes: error.maxBytes,
observedBytes: error.observedBytes,
label: error.label,
})
return NextResponse.json(
createError(
id,
error.label === 'MCP workflow execution request body'
? ErrorCode.InvalidParams
: ErrorCode.InternalError,
limitMessage(error.label, error.maxBytes),
{
code: 'payload_too_large',
maxBytes: error.maxBytes,
observedBytes: error.observedBytes,
retryable: false,
}
),
{ status: 413 }
)
}
logger.error('Error calling tool:', error)
return NextResponse.json(createError(id, ErrorCode.InternalError, 'Tool execution failed'), {
status: 500,
})
} finally {
abortSignal?.cleanup()
}
}
export const DELETE = withRouteHandler(
async (request: NextRequest, { params }: { params: Promise<RouteParams> }) => {
try {
const { serverId } = mcpServeRouteParamsSchema.parse(await params)
const server = await getServer(serverId)
if (!server) {
return NextResponse.json({ error: 'Server not found' }, { status: 404 })
}
const authResult = await authorizeMcpServeRequest(request, server, {
requireAuthForPublic: true,
})
if (authResult.response) return authResult.response
logger.info(`MCP session terminated for server ${serverId}`)
return new NextResponse(null, { status: 204 })
} catch (error) {
logger.error('Error handling MCP DELETE request:', error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}
)
@@ -0,0 +1,267 @@
import { db } from '@sim/db'
import { mcpServers, workflow, workflowBlocks } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import { and, eq, inArray, isNull } from 'drizzle-orm'
import type { NextRequest } from 'next/server'
import { mcpServerIdParamsSchema } from '@/lib/api/contracts/mcp'
import { validationErrorResponse } from '@/lib/api/server'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { withMcpAuth } from '@/lib/mcp/middleware'
import { mcpService } from '@/lib/mcp/service'
import type { McpServerStatusConfig, McpTool, McpToolSchema } from '@/lib/mcp/types'
import {
createMcpErrorResponse,
createMcpSuccessResponse,
MCP_TOOL_CORE_PARAMS,
} from '@/lib/mcp/utils'
const logger = createLogger('McpServerRefreshAPI')
export const dynamic = 'force-dynamic'
/** Schema stored in workflow blocks includes description from the tool. */
type StoredToolSchema = McpToolSchema & { description?: string }
interface StoredTool {
type: string
title: string
toolId: string
params: {
serverId: string
serverUrl?: string
toolName: string
serverName?: string
}
schema?: StoredToolSchema
[key: string]: unknown
}
interface SyncResult {
updatedCount: number
updatedWorkflowIds: string[]
}
interface ServerMetadata {
url?: string
name?: string
}
/**
* Syncs tool schemas and server metadata from discovered MCP tools to all
* workflow blocks using those tools. Updates stored serverUrl/serverName
* when the server's details have changed, preventing stale badges after
* a server URL edit.
*/
async function syncToolSchemasToWorkflows(
workspaceId: string,
serverId: string,
tools: McpTool[],
requestId: string,
serverMeta?: ServerMetadata
): Promise<SyncResult> {
const toolsByName = new Map(tools.map((t) => [t.name, t]))
const workspaceWorkflows = await db
.select({ id: workflow.id })
.from(workflow)
.where(eq(workflow.workspaceId, workspaceId))
const workflowIds = workspaceWorkflows.map((w) => w.id)
if (workflowIds.length === 0) return { updatedCount: 0, updatedWorkflowIds: [] }
const agentBlocks = await db
.select({
id: workflowBlocks.id,
workflowId: workflowBlocks.workflowId,
subBlocks: workflowBlocks.subBlocks,
})
.from(workflowBlocks)
.where(and(eq(workflowBlocks.type, 'agent'), inArray(workflowBlocks.workflowId, workflowIds)))
const updatedWorkflowIds = new Set<string>()
for (const block of agentBlocks) {
const subBlocks = block.subBlocks as Record<string, unknown> | null
if (!subBlocks) continue
const toolsSubBlock = subBlocks.tools as { value?: StoredTool[] } | undefined
if (!toolsSubBlock?.value || !Array.isArray(toolsSubBlock.value)) continue
let hasUpdates = false
const updatedTools = toolsSubBlock.value.map((tool) => {
if (tool.type !== 'mcp' || tool.params?.serverId !== serverId) {
return tool
}
const freshTool = toolsByName.get(tool.params.toolName)
if (!freshTool) return tool
const newSchema: StoredToolSchema = {
...freshTool.inputSchema,
description: freshTool.description,
}
const schemasMatch = JSON.stringify(tool.schema) === JSON.stringify(newSchema)
const urlChanged = serverMeta?.url != null && tool.params.serverUrl !== serverMeta.url
const nameChanged = serverMeta?.name != null && tool.params.serverName !== serverMeta.name
if (!schemasMatch || urlChanged || nameChanged) {
hasUpdates = true
const validParamKeys = new Set(Object.keys(newSchema.properties || {}))
const cleanedParams: Record<string, unknown> = {}
for (const [key, value] of Object.entries(tool.params || {})) {
if (MCP_TOOL_CORE_PARAMS.has(key) || validParamKeys.has(key)) {
cleanedParams[key] = value
}
}
if (urlChanged) cleanedParams.serverUrl = serverMeta.url
if (nameChanged) cleanedParams.serverName = serverMeta.name
return { ...tool, schema: newSchema, params: cleanedParams }
}
return tool
})
if (hasUpdates) {
const updatedSubBlocks = {
...subBlocks,
tools: { ...toolsSubBlock, value: updatedTools },
}
await db
.update(workflowBlocks)
.set({ subBlocks: updatedSubBlocks, updatedAt: new Date() })
.where(eq(workflowBlocks.id, block.id))
updatedWorkflowIds.add(block.workflowId)
}
}
if (updatedWorkflowIds.size > 0) {
logger.info(
`[${requestId}] Synced tool schemas to ${updatedWorkflowIds.size} workflow(s) for server ${serverId}`
)
}
return {
updatedCount: updatedWorkflowIds.size,
updatedWorkflowIds: Array.from(updatedWorkflowIds),
}
}
export const POST = withRouteHandler(
withMcpAuth<{ id: string }>('read')(
async (request: NextRequest, { userId, workspaceId, requestId }, { params }) => {
try {
const paramsValidation = mcpServerIdParamsSchema.safeParse(await params)
if (!paramsValidation.success) return validationErrorResponse(paramsValidation.error)
const { id: serverId } = paramsValidation.data
logger.info(`[${requestId}] Refreshing MCP server: ${serverId}`)
const [server] = await db
.select()
.from(mcpServers)
.where(
and(
eq(mcpServers.id, serverId),
eq(mcpServers.workspaceId, workspaceId),
isNull(mcpServers.deletedAt)
)
)
.limit(1)
if (!server) {
return createMcpErrorResponse(
new Error('Server not found or access denied'),
'Server not found',
404
)
}
let connectionStatus: 'connected' | 'disconnected' | 'error' = 'error'
let toolCount = 0
let lastError: string | null = null
let syncResult: SyncResult = { updatedCount: 0, updatedWorkflowIds: [] }
let discoveredTools: McpTool[] = []
const currentStatusConfig: McpServerStatusConfig =
(server.statusConfig as McpServerStatusConfig | null) ?? {
consecutiveFailures: 0,
lastSuccessfulDiscovery: null,
}
try {
discoveredTools = await mcpService.discoverServerTools(
userId,
serverId,
workspaceId,
true
)
connectionStatus = 'connected'
toolCount = discoveredTools.length
logger.info(`[${requestId}] Discovered ${toolCount} tools from server ${serverId}`)
syncResult = await syncToolSchemasToWorkflows(
workspaceId,
serverId,
discoveredTools,
requestId,
{ url: server.url ?? undefined, name: server.name ?? undefined }
)
} catch (error) {
connectionStatus = 'error'
lastError =
error instanceof Error
? error.message.split('\n')[0].slice(0, 200)
: 'Connection failed'
logger.warn(`[${requestId}] Failed to connect to server ${serverId}:`, error)
}
const now = new Date()
const newStatusConfig =
connectionStatus === 'connected'
? { consecutiveFailures: 0, lastSuccessfulDiscovery: now.toISOString() }
: {
consecutiveFailures: currentStatusConfig.consecutiveFailures + 1,
lastSuccessfulDiscovery: currentStatusConfig.lastSuccessfulDiscovery,
}
const [refreshedServer] = await db
.update(mcpServers)
.set({
lastToolsRefresh: now,
connectionStatus,
lastError,
lastConnected: connectionStatus === 'connected' ? now : server.lastConnected,
toolCount,
statusConfig: newStatusConfig,
updatedAt: now,
})
.where(eq(mcpServers.id, serverId))
.returning()
if (connectionStatus === 'connected') {
await mcpService.clearCache(workspaceId)
}
return createMcpSuccessResponse({
status: connectionStatus,
toolCount,
lastConnected: refreshedServer?.lastConnected?.toISOString() || null,
error: lastError,
workflowsUpdated: syncResult.updatedCount,
updatedWorkflowIds: syncResult.updatedWorkflowIds,
})
} catch (error) {
logger.error(`[${requestId}] Error refreshing MCP server:`, error)
return createMcpErrorResponse(toError(error), 'Failed to refresh MCP server', 500)
}
}
)
)
@@ -0,0 +1,96 @@
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import type { NextRequest } from 'next/server'
import { updateMcpServerBodySchema } from '@/lib/api/contracts/mcp'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import {
mcpBodyReadErrorResponse,
readMcpJsonBodyWithLimit,
withMcpAuth,
} from '@/lib/mcp/middleware'
import { performUpdateMcpServer } from '@/lib/mcp/orchestration'
import {
createMcpErrorResponse,
createMcpSuccessResponse,
mcpOrchestrationStatus,
} from '@/lib/mcp/utils'
const logger = createLogger('McpServerAPI')
export const dynamic = 'force-dynamic'
/**
* PATCH - Update an MCP server in the workspace (requires write or admin permission)
*/
export const PATCH = withRouteHandler(
withMcpAuth<{ id: string }>('write')(
async (
request: NextRequest,
{ userId, userName, userEmail, workspaceId, requestId },
{ params }
) => {
try {
const { id: serverId } = await params
const rawBody = await readMcpJsonBodyWithLimit(request)
const parsedBody = updateMcpServerBodySchema.safeParse(rawBody)
if (!parsedBody.success) {
return createMcpErrorResponse(parsedBody.error, 'Invalid request format', 400)
}
const body = parsedBody.data
logger.info(
`[${requestId}] Updating MCP server: ${serverId} in workspace: ${workspaceId}`,
{
userId,
updates: Object.keys(body).filter((k) => k !== 'workspaceId'),
}
)
const result = await performUpdateMcpServer({
workspaceId,
userId,
actorName: userName,
actorEmail: userEmail,
serverId,
name: body.name,
description: body.description,
transport: body.transport,
url: body.url,
headers: body.headers,
timeout: body.timeout,
retries: body.retries,
enabled: body.enabled,
authType: body.authType,
oauthClientId: body.oauthClientId || null,
oauthClientIdProvided: body.oauthClientId !== undefined,
oauthClientSecret: body.oauthClientSecret,
oauthClientSecretProvided: body.oauthClientSecret !== undefined,
request,
})
if (!result.success || !result.server) {
return createMcpErrorResponse(
new Error('Server not found or access denied'),
result.error || 'Server not found',
mcpOrchestrationStatus(result.errorCode)
)
}
const updatedServer = result.server
logger.info(`[${requestId}] Successfully updated MCP server: ${serverId}`)
const { oauthClientSecret: _secret, ...rest } = updatedServer
return createMcpSuccessResponse({
server: { ...rest, hasOauthClientSecret: !!_secret },
})
} catch (error) {
const bodyErrorResponse = mcpBodyReadErrorResponse(error, request)
if (bodyErrorResponse) return bodyErrorResponse
logger.error(`[${requestId}] Error updating MCP server:`, error)
return createMcpErrorResponse(toError(error), 'Failed to update MCP server', 500)
}
}
)
)
@@ -0,0 +1,89 @@
/**
* @vitest-environment node
*/
import type { NextRequest } from 'next/server'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { mockPerformDeleteMcpServer } = vi.hoisted(() => ({
mockPerformDeleteMcpServer: vi.fn(),
}))
vi.mock('@sim/db', () => ({
db: {
select: vi.fn(),
},
}))
vi.mock('@/lib/mcp/middleware', () => ({
getParsedBody: () => undefined,
withMcpAuth:
() =>
(
handler: (
request: NextRequest,
context: {
userId: string
userName: string
userEmail: string
workspaceId: string
requestId: string
}
) => Promise<Response>
) =>
(request: NextRequest) =>
handler(request, {
userId: 'user-1',
userName: 'Test User',
userEmail: 'test@example.com',
workspaceId: 'workspace-1',
requestId: 'request-1',
}),
}))
vi.mock('@/lib/mcp/orchestration', () => ({
performCreateMcpServer: vi.fn(),
performDeleteMcpServer: mockPerformDeleteMcpServer,
}))
import { DELETE } from '@/app/api/mcp/servers/route'
function createDeleteRequest(serverId = 'server-1') {
return new Request(
`http://localhost:3000/api/mcp/servers?workspaceId=workspace-1&serverId=${serverId}`,
{ method: 'DELETE' }
) as NextRequest
}
describe('MCP servers DELETE route', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('returns 404 when orchestration reports a missing server', async () => {
mockPerformDeleteMcpServer.mockResolvedValueOnce({
success: false,
error: 'Server not found',
errorCode: 'not_found',
})
const response = await DELETE(createDeleteRequest())
const body = await response.json()
expect(response.status).toBe(404)
expect(body).toEqual({ success: false, error: 'Server not found' })
})
it('returns 500 when orchestration reports an internal delete failure', async () => {
mockPerformDeleteMcpServer.mockResolvedValueOnce({
success: false,
error: 'Failed to delete MCP server',
errorCode: 'internal',
})
const response = await DELETE(createDeleteRequest())
const body = await response.json()
expect(response.status).toBe(500)
expect(body).toEqual({ success: false, error: 'Failed to delete MCP server' })
})
})
+192
View File
@@ -0,0 +1,192 @@
import { db } from '@sim/db'
import { mcpServers } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import { and, eq, isNull } from 'drizzle-orm'
import type { NextRequest } from 'next/server'
import { createMcpServerBodySchema, deleteMcpServerByQuerySchema } from '@/lib/api/contracts/mcp'
import { validationErrorResponse } from '@/lib/api/server'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import {
mcpBodyReadErrorResponse,
readMcpJsonBodyWithLimit,
withMcpAuth,
} from '@/lib/mcp/middleware'
import { performCreateMcpServer, performDeleteMcpServer } from '@/lib/mcp/orchestration'
import {
createMcpErrorResponse,
createMcpSuccessResponse,
mcpOrchestrationStatus,
} from '@/lib/mcp/utils'
const logger = createLogger('McpServersAPI')
export const dynamic = 'force-dynamic'
/**
* GET - List all registered MCP servers for the workspace
*/
export const GET = withRouteHandler(
withMcpAuth('read')(async (request: NextRequest, { userId, workspaceId, requestId }) => {
try {
logger.info(`[${requestId}] Listing MCP servers for workspace ${workspaceId}`)
const rows = await db
.select()
.from(mcpServers)
.where(and(eq(mcpServers.workspaceId, workspaceId), isNull(mcpServers.deletedAt)))
const servers = rows.map(({ oauthClientSecret: _secret, ...rest }) => ({
...rest,
hasOauthClientSecret: !!_secret,
}))
logger.info(
`[${requestId}] Listed ${servers.length} MCP servers for workspace ${workspaceId}`
)
return createMcpSuccessResponse({ servers })
} catch (error) {
logger.error(`[${requestId}] Error listing MCP servers:`, error)
return createMcpErrorResponse(toError(error), 'Failed to list MCP servers', 500)
}
})
)
/**
* POST - Register a new MCP server for the workspace (requires write permission)
*/
export const POST = withRouteHandler(
withMcpAuth('write')(
async (request: NextRequest, { userId, userName, userEmail, workspaceId, requestId }) => {
try {
const rawBody = await readMcpJsonBodyWithLimit(request)
const parsedBody = createMcpServerBodySchema.safeParse(rawBody)
if (!parsedBody.success) {
return createMcpErrorResponse(parsedBody.error, 'Invalid request format', 400)
}
const body = parsedBody.data
logger.info(`[${requestId}] Registering MCP server:`, {
name: body.name,
transport: body.transport,
workspaceId,
})
const sourceParam = body.source as string | undefined
const source =
sourceParam === 'settings' || sourceParam === 'tool_input' ? sourceParam : undefined
if (!body.url) {
return createMcpErrorResponse(
new Error('url is required'),
'Missing required parameter',
400
)
}
const result = await performCreateMcpServer({
workspaceId,
userId,
actorName: userName,
actorEmail: userEmail,
name: body.name,
description: body.description,
transport: body.transport,
url: body.url,
headers: body.headers,
timeout: body.timeout,
retries: body.retries,
enabled: body.enabled,
source,
authType: body.authType,
oauthClientId: body.oauthClientId || null,
oauthClientIdProvided: body.oauthClientId !== undefined,
oauthClientSecret: body.oauthClientSecret,
oauthClientSecretProvided: body.oauthClientSecret !== undefined,
request,
})
if (!result.success || !result.serverId) {
return createMcpErrorResponse(
new Error(result.error || 'Failed to register MCP server'),
result.error || 'Failed to register MCP server',
mcpOrchestrationStatus(result.errorCode)
)
}
logger.info(
`[${requestId}] Successfully registered MCP server: ${body.name} (ID: ${result.serverId})`
)
return createMcpSuccessResponse(
result.updated
? { serverId: result.serverId, updated: true, authType: result.authType }
: { serverId: result.serverId, authType: result.authType },
result.updated ? 200 : 201
)
} catch (error) {
const bodyErrorResponse = mcpBodyReadErrorResponse(error, request)
if (bodyErrorResponse) return bodyErrorResponse
logger.error(`[${requestId}] Error registering MCP server:`, error)
return createMcpErrorResponse(toError(error), 'Failed to register MCP server', 500)
}
}
)
)
/**
* DELETE - Delete an MCP server from the workspace (requires write permission)
*/
export const DELETE = withRouteHandler(
withMcpAuth('write')(
async (request: NextRequest, { userId, userName, userEmail, workspaceId, requestId }) => {
try {
const { searchParams } = new URL(request.url)
const queryValidation = deleteMcpServerByQuerySchema.safeParse(
Object.fromEntries(searchParams)
)
if (!queryValidation.success) return validationErrorResponse(queryValidation.error)
const query = queryValidation.data
const serverId = query.serverId
const sourceParam = query.source
const source =
sourceParam === 'settings' || sourceParam === 'tool_input' ? sourceParam : undefined
if (!serverId) {
return createMcpErrorResponse(
new Error('serverId parameter is required'),
'Missing required parameter',
400
)
}
logger.info(
`[${requestId}] Deleting MCP server: ${serverId} from workspace: ${workspaceId}`
)
const result = await performDeleteMcpServer({
workspaceId,
userId,
actorName: userName,
actorEmail: userEmail,
serverId,
source,
request,
})
if (!result.success || !result.server) {
return createMcpErrorResponse(
new Error(result.error || 'Failed to delete MCP server'),
result.error || 'Failed to delete MCP server',
mcpOrchestrationStatus(result.errorCode)
)
}
logger.info(`[${requestId}] Successfully deleted MCP server: ${serverId}`)
return createMcpSuccessResponse({ message: `Server ${serverId} deleted successfully` })
} catch (error) {
logger.error(`[${requestId}] Error deleting MCP server:`, error)
return createMcpErrorResponse(toError(error), 'Failed to delete MCP server', 500)
}
}
)
)
@@ -0,0 +1,249 @@
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import { truncate } from '@sim/utils/string'
import type { NextRequest } from 'next/server'
import { mcpServerTestBodySchema } from '@/lib/api/contracts/mcp'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { McpClient } from '@/lib/mcp/client'
import {
McpDnsResolutionError,
McpDomainNotAllowedError,
McpSsrfError,
validateMcpDomain,
validateMcpServerSsrf,
} from '@/lib/mcp/domain-check'
import {
mcpBodyReadErrorResponse,
readMcpJsonBodyWithLimit,
withMcpAuth,
} from '@/lib/mcp/middleware'
import { detectMcpAuthType } from '@/lib/mcp/oauth'
import { resolveMcpConfigEnvVars } from '@/lib/mcp/resolve-config'
import type { McpAuthType, McpTransport } from '@/lib/mcp/types'
import { createMcpErrorResponse, createMcpSuccessResponse } from '@/lib/mcp/utils'
const logger = createLogger('McpServerTestAPI')
export const dynamic = 'force-dynamic'
/**
* Check if transport type requires a URL
* All modern MCP connections use Streamable HTTP which requires a URL
*/
function isUrlBasedTransport(transport: McpTransport): boolean {
return transport === 'streamable-http'
}
interface TestConnectionResult {
success: boolean
error?: string
authRequired?: boolean
authType?: McpAuthType
serverInfo?: {
name: string
version: string
}
negotiatedVersion?: string
supportedCapabilities?: string[]
toolCount?: number
warnings?: string[]
}
/**
* Extracts a user-friendly error message from connection errors.
* Keeps diagnostic info (timeout, DNS, HTTP status) but strips
* verbose internals (Zod details, full response bodies, stack traces).
*/
function sanitizeConnectionError(error: unknown): string {
if (!(error instanceof Error)) {
return 'Unknown connection error'
}
const firstLine = error.message.split('\n')[0]
return truncate(firstLine, 200)
}
/**
* POST - Test connection to an MCP server before registering it
*/
export const POST = withRouteHandler(
withMcpAuth('write')(async (request: NextRequest, { userId, workspaceId, requestId }) => {
try {
const rawBody = await readMcpJsonBodyWithLimit(request)
const parsedBody = mcpServerTestBodySchema.safeParse(rawBody)
if (!parsedBody.success) {
return createMcpErrorResponse(parsedBody.error, 'Invalid request format', 400)
}
const body = parsedBody.data
logger.info(`[${requestId}] Testing MCP server connection:`, {
name: body.name,
transport: body.transport,
url: body.url ? `${body.url.substring(0, 50)}...` : undefined, // Partial URL for security
workspaceId,
})
if (isUrlBasedTransport(body.transport) && !body.url) {
return createMcpErrorResponse(
new Error('URL is required for HTTP-based transports'),
'Missing required URL',
400
)
}
try {
validateMcpDomain(body.url)
} catch (e) {
if (e instanceof McpDomainNotAllowedError) {
return createMcpErrorResponse(e, e.message, 403)
}
throw e
}
try {
// Initial pre-resolution check; the authoritative resolved IP is
// captured after env-var resolution below and used to pin the
// connection against DNS rebinding.
await validateMcpServerSsrf(body.url)
} catch (e) {
if (e instanceof McpDnsResolutionError) {
return createMcpErrorResponse(e, e.message, 502)
}
if (e instanceof McpSsrfError) {
return createMcpErrorResponse(e, e.message, 403)
}
throw e
}
// Build initial config for resolution
const initialConfig = {
id: `test-${requestId}`,
name: body.name,
transport: body.transport,
url: body.url,
headers: body.headers || {},
timeout: body.timeout || 10000,
retries: 1, // Only one retry for tests
enabled: true,
}
// Resolve env vars using shared utility (non-strict mode for testing)
const { config: testConfig, missingVars } = await resolveMcpConfigEnvVars(
initialConfig,
userId,
workspaceId,
{ strict: false }
)
if (missingVars.length > 0) {
logger.warn(`[${requestId}] Some environment variables not found:`, { missingVars })
}
// Re-validate domain and SSRF after env var resolution
try {
validateMcpDomain(testConfig.url)
} catch (e) {
if (e instanceof McpDomainNotAllowedError) {
return createMcpErrorResponse(e, e.message, 403)
}
throw e
}
let resolvedIP: string | null
try {
resolvedIP = await validateMcpServerSsrf(testConfig.url)
} catch (e) {
if (e instanceof McpDnsResolutionError) {
return createMcpErrorResponse(e, e.message, 502)
}
if (e instanceof McpSsrfError) {
return createMcpErrorResponse(e, e.message, 403)
}
throw e
}
const testSecurityPolicy = {
requireConsent: false,
auditLevel: 'none' as const,
maxToolExecutionsPerHour: 0,
}
const result: TestConnectionResult = { success: false }
// Skip unauth connect when the server returns an RFC 9728 OAuth challenge.
if (testConfig.url) {
const detectedAuthType = await detectMcpAuthType(testConfig.url, resolvedIP)
if (detectedAuthType === 'oauth') {
result.authRequired = true
result.authType = 'oauth'
return createMcpSuccessResponse(result, 200)
}
result.authType = detectedAuthType
}
let client: McpClient | null = null
try {
client = new McpClient({
config: testConfig,
securityPolicy: testSecurityPolicy,
resolvedIP: resolvedIP ?? undefined,
})
await client.connect()
result.negotiatedVersion = client.getNegotiatedVersion()
try {
const tools = await client.listTools()
result.toolCount = tools.length
result.success = true
} catch (toolError) {
logger.warn(`[${requestId}] Connection established but could not list tools:`, toolError)
result.success = false
result.error = 'Connection established but could not list tools'
result.warnings = result.warnings || []
result.warnings.push(
'Server connected but tool listing failed - connection may be incomplete'
)
}
const clientVersionInfo = McpClient.getVersionInfo()
if (result.negotiatedVersion !== clientVersionInfo.preferred) {
result.warnings = result.warnings || []
result.warnings.push(
`Server uses protocol version '${result.negotiatedVersion}' instead of preferred '${clientVersionInfo.preferred}'`
)
}
logger.info(`[${requestId}] MCP server test successful:`, {
name: body.name,
negotiatedVersion: result.negotiatedVersion,
toolCount: result.toolCount,
capabilities: result.supportedCapabilities,
})
} catch (error) {
logger.warn(`[${requestId}] MCP server test failed:`, error)
result.success = false
result.error = sanitizeConnectionError(error)
} finally {
if (client) {
try {
await client.disconnect()
} catch (disconnectError) {
logger.debug(`[${requestId}] Test client disconnect error (expected):`, disconnectError)
}
}
}
return createMcpSuccessResponse(result, result.success ? 200 : 400)
} catch (error) {
const bodyErrorResponse = mcpBodyReadErrorResponse(error, request)
if (bodyErrorResponse) return bodyErrorResponse
logger.error(`[${requestId}] Error testing MCP server connection:`, error)
return createMcpErrorResponse(toError(error), 'Failed to test server connection', 500)
}
})
)
@@ -0,0 +1,162 @@
import { UnauthorizedError } from '@modelcontextprotocol/sdk/client/auth.js'
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import type { NextRequest } from 'next/server'
import { mcpToolDiscoveryQuerySchema, refreshMcpToolsBodySchema } from '@/lib/api/contracts/mcp'
import { validationErrorResponse } from '@/lib/api/server'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import {
mcpBodyReadErrorResponse,
readMcpJsonBodyWithLimit,
withMcpAuth,
} from '@/lib/mcp/middleware'
import { mcpService } from '@/lib/mcp/service'
import { McpOauthAuthorizationRequiredError, type McpToolDiscoveryResponse } from '@/lib/mcp/types'
import { categorizeError, createMcpErrorResponse, createMcpSuccessResponse } from '@/lib/mcp/utils'
const logger = createLogger('McpToolDiscoveryAPI')
const MCP_REFRESH_DISCOVERY_CONCURRENCY = 5
export const dynamic = 'force-dynamic'
async function settleWithConcurrency<T, R>(
items: T[],
concurrency: number,
task: (item: T) => Promise<R>
): Promise<Array<PromiseSettledResult<R>>> {
const results: Array<PromiseSettledResult<R> | undefined> = new Array(items.length)
let nextIndex = 0
const workers = Array.from({ length: Math.min(concurrency, items.length) }, async () => {
while (nextIndex < items.length) {
const index = nextIndex
nextIndex += 1
try {
results[index] = { status: 'fulfilled', value: await task(items[index]) }
} catch (reason) {
results[index] = { status: 'rejected', reason }
}
}
})
await Promise.all(workers)
return results.map(
(result) =>
result ?? {
status: 'rejected',
reason: new Error('MCP refresh discovery task did not run'),
}
)
}
export const GET = withRouteHandler(
withMcpAuth('read')(async (request: NextRequest, { userId, workspaceId, requestId }) => {
try {
const { searchParams } = new URL(request.url)
const queryValidation = mcpToolDiscoveryQuerySchema.safeParse(
Object.fromEntries(searchParams)
)
if (!queryValidation.success) return validationErrorResponse(queryValidation.error)
const query = queryValidation.data
const serverId = query.serverId
const forceRefresh = query.refresh === 'true'
logger.info(`[${requestId}] Discovering MCP tools`, { serverId, workspaceId, forceRefresh })
const tools = serverId
? await mcpService.discoverServerTools(userId, serverId, workspaceId, forceRefresh)
: await mcpService.discoverTools(userId, workspaceId, forceRefresh)
const byServer: Record<string, number> = {}
for (const tool of tools) {
byServer[tool.serverId] = (byServer[tool.serverId] || 0) + 1
}
const responseData: McpToolDiscoveryResponse = {
tools,
totalCount: tools.length,
byServer,
}
logger.info(
`[${requestId}] Discovered ${tools.length} tools from ${Object.keys(byServer).length} servers`
)
return createMcpSuccessResponse(responseData)
} catch (error) {
if (
error instanceof McpOauthAuthorizationRequiredError ||
error instanceof UnauthorizedError
) {
return createMcpErrorResponse(error, 'OAuth re-authorization required', 401)
}
logger.error(`[${requestId}] Error discovering MCP tools:`, error)
const { message, status } = categorizeError(error)
return createMcpErrorResponse(new Error(message), 'Failed to discover MCP tools', status)
}
})
)
export const POST = withRouteHandler(
withMcpAuth('read')(async (request: NextRequest, { userId, workspaceId, requestId }) => {
try {
const rawBody = await readMcpJsonBodyWithLimit(request)
const parsedBody = refreshMcpToolsBodySchema.safeParse(rawBody)
if (!parsedBody.success) {
return createMcpErrorResponse(parsedBody.error, 'Invalid request format', 400)
}
const { serverIds } = parsedBody.data
logger.info(`[${requestId}] Refreshing tools for ${serverIds.length} servers`)
const results = await settleWithConcurrency(
serverIds,
MCP_REFRESH_DISCOVERY_CONCURRENCY,
async (serverId: string) => {
const tools = await mcpService.discoverServerTools(userId, serverId, workspaceId, true)
return { serverId, toolCount: tools.length }
}
)
const successes: Array<{ serverId: string; toolCount: number }> = []
const failures: Array<{ serverId: string; error: string }> = []
results.forEach((result, index) => {
const serverId = serverIds[index]
if (result.status === 'fulfilled') {
successes.push(result.value)
} else {
failures.push({
serverId,
error: getErrorMessage(result.reason, 'Unknown error'),
})
}
})
logger.info(`[${requestId}] Refresh completed: ${successes.length}/${serverIds.length}`)
return createMcpSuccessResponse({
refreshed: successes,
failed: failures,
summary: {
total: serverIds.length,
successful: successes.length,
failed: failures.length,
},
})
} catch (error) {
const bodyErrorResponse = mcpBodyReadErrorResponse(error, request)
if (bodyErrorResponse) return bodyErrorResponse
if (
error instanceof McpOauthAuthorizationRequiredError ||
error instanceof UnauthorizedError
) {
return createMcpErrorResponse(error, 'OAuth re-authorization required', 401)
}
logger.error(`[${requestId}] Error refreshing tool discovery:`, error)
const { message, status } = categorizeError(error)
return createMcpErrorResponse(new Error(message), 'Failed to refresh tool discovery', status)
}
})
)
+344
View File
@@ -0,0 +1,344 @@
import { UnauthorizedError } from '@modelcontextprotocol/sdk/client/auth.js'
import { createLogger } from '@sim/logger'
import type { NextRequest } from 'next/server'
import { NextResponse } from 'next/server'
import { mcpToolExecutionBodySchema } from '@/lib/api/contracts/mcp'
import { getHighestPrioritySubscription } from '@/lib/billing/core/plan'
import { getExecutionTimeout } from '@/lib/core/execution-limits'
import type { SubscriptionPlan } from '@/lib/core/rate-limiter/types'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { SIM_VIA_HEADER } from '@/lib/execution/call-chain'
import {
mcpBodyReadErrorResponse,
readMcpJsonBodyWithLimit,
withMcpAuth,
} from '@/lib/mcp/middleware'
import { McpOauthRedirectRequired } from '@/lib/mcp/oauth'
import { mcpService } from '@/lib/mcp/service'
import {
McpOauthAuthorizationRequiredError,
type McpTool,
type McpToolCall,
type McpToolResult,
} from '@/lib/mcp/types'
import { categorizeError, createMcpErrorResponse, createMcpSuccessResponse } from '@/lib/mcp/utils'
import {
assertPermissionsAllowed,
McpToolsNotAllowedError,
} from '@/ee/access-control/utils/permission-check'
const logger = createLogger('McpToolExecutionAPI')
export const dynamic = 'force-dynamic'
interface SchemaProperty {
type: 'string' | 'number' | 'integer' | 'boolean' | 'object' | 'array'
description?: string
enum?: unknown[]
format?: string
items?: SchemaProperty
properties?: Record<string, SchemaProperty>
}
interface ToolExecutionResult {
success: boolean
output?: McpToolResult
error?: string
}
function hasType(prop: unknown): prop is SchemaProperty {
return typeof prop === 'object' && prop !== null && 'type' in prop
}
/**
* POST - Execute a tool on an MCP server
*/
export const POST = withRouteHandler(
withMcpAuth('read')(async (request: NextRequest, { userId, workspaceId, requestId }) => {
let serverId: string | undefined
try {
const rawBody = await readMcpJsonBodyWithLimit(request)
const parsedBody = mcpToolExecutionBodySchema.safeParse(rawBody)
if (!parsedBody.success) {
return createMcpErrorResponse(parsedBody.error, 'Invalid request format', 400)
}
const body = parsedBody.data
logger.info(`[${requestId}] MCP tool execution request received`, {
hasAuthHeader: !!request.headers.get('authorization'),
bodyKeys: Object.keys(body),
serverId: body.serverId,
toolName: body.toolName,
hasWorkflowId: !!body.workflowId,
workflowId: body.workflowId,
userId: userId,
})
const { toolName, arguments: rawArgs } = body
serverId = body.serverId
const args = rawArgs || {}
try {
await assertPermissionsAllowed({
userId,
workspaceId,
toolKind: 'mcp',
})
} catch (err) {
if (err instanceof McpToolsNotAllowedError) {
return createMcpErrorResponse(err, err.message, 403)
}
throw err
}
logger.info(
`[${requestId}] Executing tool ${toolName} on server ${serverId} for user ${userId} in workspace ${workspaceId}`
)
let tool: McpTool | null = null
try {
const tools = await mcpService.discoverServerTools(userId, serverId, workspaceId)
tool = tools.find((t) => t.name === toolName) ?? null
if (!tool) {
logger.warn(`[${requestId}] Tool ${toolName} not found on server ${serverId}`, {
availableTools: tools.map((t) => t.name),
})
return createMcpErrorResponse(
new Error('Tool not found'),
'Tool not found on the specified server',
404
)
}
if (tool.inputSchema?.properties) {
for (const [paramName, paramSchema] of Object.entries(tool.inputSchema.properties)) {
const schema = hasType(paramSchema) ? paramSchema : null
if (!schema) continue
const value = args[paramName]
if (value === undefined || value === null) {
continue
}
if (
(schema.type === 'number' || schema.type === 'integer') &&
typeof value === 'string'
) {
const numValue =
schema.type === 'integer' ? Number.parseInt(value) : Number.parseFloat(value)
if (!Number.isNaN(numValue)) {
args[paramName] = numValue
}
} else if (schema.type === 'boolean' && typeof value === 'string') {
if (value.toLowerCase() === 'true') {
args[paramName] = true
} else if (value.toLowerCase() === 'false') {
args[paramName] = false
}
} else if (schema.type === 'array' && typeof value === 'string') {
const stringValue = value.trim()
if (stringValue) {
try {
const parsed = JSON.parse(stringValue)
if (Array.isArray(parsed)) {
args[paramName] = parsed
} else {
args[paramName] = [parsed]
}
} catch {
if (stringValue.includes(',')) {
args[paramName] = stringValue
.split(',')
.map((item) => item.trim())
.filter((item) => item)
} else {
args[paramName] = [stringValue]
}
}
} else {
args[paramName] = []
}
}
}
}
} catch (error) {
logger.warn(
`[${requestId}] Failed to discover tools for validation, proceeding without schema`,
error
)
}
if (tool) {
const validationError = validateToolArguments(tool, args)
if (validationError) {
logger.warn(`[${requestId}] Tool validation failed: ${validationError}`)
return createMcpErrorResponse(
new Error(`Invalid arguments for tool ${toolName}: ${validationError}`),
'Invalid tool arguments',
400
)
}
}
const toolCall: McpToolCall = {
name: toolName,
arguments: args,
}
const userSubscription = await getHighestPrioritySubscription(userId)
const executionTimeout = getExecutionTimeout(
userSubscription?.plan as SubscriptionPlan | undefined,
'sync'
)
const simViaHeader = request.headers.get(SIM_VIA_HEADER)
const extraHeaders: Record<string, string> = {}
if (simViaHeader) {
extraHeaders[SIM_VIA_HEADER] = simViaHeader
}
let timeoutHandle: ReturnType<typeof setTimeout> | undefined
const result = await Promise.race([
mcpService.executeTool(userId, serverId, toolCall, workspaceId, extraHeaders),
new Promise<never>((_, reject) => {
timeoutHandle = setTimeout(
() => reject(new Error('Tool execution timeout')),
executionTimeout
)
}),
]).finally(() => {
if (timeoutHandle !== undefined) clearTimeout(timeoutHandle)
})
const transformedResult = transformToolResult(result)
if (result.isError) {
logger.warn(`[${requestId}] Tool execution returned error for ${toolName} on ${serverId}`)
return createMcpErrorResponse(
transformedResult,
transformedResult.error || 'Tool execution failed',
400
)
}
logger.info(`[${requestId}] Successfully executed tool ${toolName} on server ${serverId}`)
try {
const { PlatformEvents } = await import('@/lib/core/telemetry')
PlatformEvents.mcpToolExecuted({
serverId,
toolName,
status: 'success',
workspaceId,
})
} catch {
// Telemetry failure is non-critical
}
return createMcpSuccessResponse(transformedResult)
} catch (error) {
const bodyErrorResponse = mcpBodyReadErrorResponse(error, request)
if (bodyErrorResponse) return bodyErrorResponse
if (
error instanceof McpOauthAuthorizationRequiredError ||
error instanceof McpOauthRedirectRequired ||
error instanceof UnauthorizedError
) {
const errorServerId =
error instanceof McpOauthAuthorizationRequiredError ? error.serverId : serverId
logger.warn(`[${requestId}] OAuth re-authorization required for MCP tool execution`, {
serverId: errorServerId,
})
return NextResponse.json(
{
success: false,
error: 'OAuth re-authorization required',
code: 'reauth_required',
serverId: errorServerId,
},
{ status: 401 }
)
}
logger.error(`[${requestId}] Error executing MCP tool:`, error)
const { message, status } = categorizeError(error)
return createMcpErrorResponse(new Error(message), message, status)
}
})
)
function validateToolArguments(tool: McpTool, args: Record<string, unknown>): string | null {
if (!tool.inputSchema) {
return null
}
const schema = tool.inputSchema
if (schema.required && Array.isArray(schema.required)) {
for (const requiredProp of schema.required) {
if (!(requiredProp in (args || {}))) {
return `Missing required property: ${requiredProp}`
}
}
}
if (schema.properties && args) {
for (const [propName, propSchema] of Object.entries(schema.properties)) {
const propValue = args[propName]
if (propValue !== undefined && hasType(propSchema)) {
const expectedType = propSchema.type
const actualType = typeof propValue
if (expectedType === 'string' && actualType !== 'string') {
return `Property ${propName} must be a string`
}
if (expectedType === 'number' && actualType !== 'number') {
return `Property ${propName} must be a number`
}
if (
expectedType === 'integer' &&
(actualType !== 'number' || !Number.isInteger(propValue))
) {
return `Property ${propName} must be an integer`
}
if (expectedType === 'boolean' && actualType !== 'boolean') {
return `Property ${propName} must be a boolean`
}
if (
expectedType === 'object' &&
(actualType !== 'object' || propValue === null || Array.isArray(propValue))
) {
return `Property ${propName} must be an object`
}
if (expectedType === 'array' && !Array.isArray(propValue)) {
return `Property ${propName} must be an array`
}
}
}
}
return null
}
function transformToolResult(result: McpToolResult): ToolExecutionResult {
if (result.isError) {
const firstContent = Array.isArray(result.content) ? result.content[0] : undefined
const errorText =
firstContent && typeof firstContent === 'object' && typeof firstContent.text === 'string'
? firstContent.text
: undefined
return {
success: false,
error: errorText && errorText.trim().length > 0 ? errorText : 'Tool execution failed',
}
}
return {
success: true,
output: result,
}
}
@@ -0,0 +1,78 @@
import { db } from '@sim/db'
import { workflow, workflowBlocks } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import { and, eq, inArray } from 'drizzle-orm'
import type { NextRequest } from 'next/server'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { withMcpAuth } from '@/lib/mcp/middleware'
import type { McpToolSchema, StoredMcpTool } from '@/lib/mcp/types'
import { createMcpErrorResponse, createMcpSuccessResponse } from '@/lib/mcp/utils'
const logger = createLogger('McpStoredToolsAPI')
export const dynamic = 'force-dynamic'
export const GET = withRouteHandler(
withMcpAuth('read')(async (request: NextRequest, { userId, workspaceId, requestId }) => {
try {
logger.info(`[${requestId}] Fetching stored MCP tools for workspace ${workspaceId}`)
const workflows = await db
.select({ id: workflow.id, name: workflow.name })
.from(workflow)
.where(eq(workflow.workspaceId, workspaceId))
const workflowMap = new Map(workflows.map((w) => [w.id, w.name]))
const workflowIds = workflows.map((w) => w.id)
if (workflowIds.length === 0) {
return createMcpSuccessResponse({ tools: [] })
}
const agentBlocks = await db
.select({ workflowId: workflowBlocks.workflowId, subBlocks: workflowBlocks.subBlocks })
.from(workflowBlocks)
.where(
and(eq(workflowBlocks.type, 'agent'), inArray(workflowBlocks.workflowId, workflowIds))
)
const storedTools: StoredMcpTool[] = []
for (const block of agentBlocks) {
const subBlocks = block.subBlocks as Record<string, unknown> | null
if (!subBlocks) continue
const toolsSubBlock = subBlocks.tools as Record<string, unknown> | undefined
const toolsValue = toolsSubBlock?.value
if (!toolsValue || !Array.isArray(toolsValue)) continue
for (const tool of toolsValue) {
if (tool.type !== 'mcp') continue
const params = tool.params as Record<string, unknown> | undefined
if (!params?.serverId || !params?.toolName) continue
storedTools.push({
workflowId: block.workflowId,
workflowName: workflowMap.get(block.workflowId) || 'Untitled',
serverId: params.serverId as string,
serverUrl: params.serverUrl as string | undefined,
toolName: params.toolName as string,
schema: tool.schema as McpToolSchema | undefined,
})
}
}
logger.info(
`[${requestId}] Found ${storedTools.length} stored MCP tools across ${workflows.length} workflows`
)
return createMcpSuccessResponse({ tools: storedTools })
} catch (error) {
logger.error(`[${requestId}] Error fetching stored MCP tools:`, error)
return createMcpErrorResponse(toError(error), 'Failed to fetch stored MCP tools', 500)
}
})
)
@@ -0,0 +1,186 @@
import { db } from '@sim/db'
import { workflowMcpServer, workflowMcpTool } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import { and, eq, isNull } from 'drizzle-orm'
import type { NextRequest } from 'next/server'
import {
updateWorkflowMcpServerBodySchema,
workflowMcpServerParamsSchema,
} from '@/lib/api/contracts/workflow-mcp-servers'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import {
mcpBodyReadErrorResponse,
readMcpJsonBodyWithLimit,
withMcpAuth,
} from '@/lib/mcp/middleware'
import {
performDeleteWorkflowMcpServer,
performUpdateWorkflowMcpServer,
} from '@/lib/mcp/orchestration'
import {
createMcpErrorResponse,
createMcpSuccessResponse,
mcpOrchestrationStatus,
} from '@/lib/mcp/utils'
const logger = createLogger('WorkflowMcpServerAPI')
export const dynamic = 'force-dynamic'
interface RouteParams {
id: string
}
/**
* GET - Get a specific workflow MCP server with its tools
*/
export const GET = withRouteHandler(
withMcpAuth<RouteParams>('read')(
async (request: NextRequest, { userId, workspaceId, requestId }, { params }) => {
try {
const { id: serverId } = workflowMcpServerParamsSchema.parse(await params)
logger.info(`[${requestId}] Getting workflow MCP server: ${serverId}`)
const [server] = await db
.select({
id: workflowMcpServer.id,
workspaceId: workflowMcpServer.workspaceId,
createdBy: workflowMcpServer.createdBy,
name: workflowMcpServer.name,
description: workflowMcpServer.description,
isPublic: workflowMcpServer.isPublic,
createdAt: workflowMcpServer.createdAt,
updatedAt: workflowMcpServer.updatedAt,
})
.from(workflowMcpServer)
.where(
and(
eq(workflowMcpServer.id, serverId),
eq(workflowMcpServer.workspaceId, workspaceId),
isNull(workflowMcpServer.deletedAt)
)
)
.limit(1)
if (!server) {
return createMcpErrorResponse(new Error('Server not found'), 'Server not found', 404)
}
const tools = await db
.select()
.from(workflowMcpTool)
.where(and(eq(workflowMcpTool.serverId, serverId), isNull(workflowMcpTool.archivedAt)))
logger.info(
`[${requestId}] Found workflow MCP server: ${server.name} with ${tools.length} tools`
)
return createMcpSuccessResponse({ server, tools })
} catch (error) {
logger.error(`[${requestId}] Error getting workflow MCP server:`, error)
return createMcpErrorResponse(toError(error), 'Failed to get workflow MCP server', 500)
}
}
)
)
/**
* PATCH - Update a workflow MCP server
*/
export const PATCH = withRouteHandler(
withMcpAuth<RouteParams>('write')(
async (
request: NextRequest,
{ userId, userName, userEmail, workspaceId, requestId },
{ params }
) => {
try {
const { id: serverId } = workflowMcpServerParamsSchema.parse(await params)
const rawBody = await readMcpJsonBodyWithLimit(request)
const parsedBody = updateWorkflowMcpServerBodySchema.safeParse(rawBody)
if (!parsedBody.success) {
return createMcpErrorResponse(parsedBody.error, 'Invalid request format', 400)
}
const body = parsedBody.data
logger.info(`[${requestId}] Updating workflow MCP server: ${serverId}`)
const result = await performUpdateWorkflowMcpServer({
serverId,
workspaceId,
userId,
actorName: userName,
actorEmail: userEmail,
name: body.name,
description: body.description,
isPublic: body.isPublic,
})
if (!result.success || !result.server) {
const status = mcpOrchestrationStatus(result.errorCode)
return createMcpErrorResponse(
new Error(result.error || 'Failed to update workflow MCP server'),
result.error || 'Failed to update workflow MCP server',
status
)
}
const updatedServer = result.server
logger.info(`[${requestId}] Successfully updated workflow MCP server: ${serverId}`)
return createMcpSuccessResponse({ server: updatedServer })
} catch (error) {
const bodyErrorResponse = mcpBodyReadErrorResponse(error, request)
if (bodyErrorResponse) return bodyErrorResponse
logger.error(`[${requestId}] Error updating workflow MCP server:`, error)
return createMcpErrorResponse(toError(error), 'Failed to update workflow MCP server', 500)
}
}
)
)
/**
* DELETE - Delete a workflow MCP server and all its tools
*/
export const DELETE = withRouteHandler(
withMcpAuth<RouteParams>('write')(
async (
request: NextRequest,
{ userId, userName, userEmail, workspaceId, requestId },
{ params }
) => {
try {
const { id: serverId } = workflowMcpServerParamsSchema.parse(await params)
logger.info(`[${requestId}] Deleting workflow MCP server: ${serverId}`)
const result = await performDeleteWorkflowMcpServer({
serverId,
workspaceId,
userId,
actorName: userName,
actorEmail: userEmail,
})
if (!result.success || !result.server) {
return createMcpErrorResponse(
new Error(result.error || 'Server not found'),
result.error || 'Server not found',
mcpOrchestrationStatus(result.errorCode)
)
}
const deletedServer = result.server
logger.info(`[${requestId}] Successfully deleted workflow MCP server: ${serverId}`)
return createMcpSuccessResponse({ message: `Server ${serverId} deleted successfully` })
} catch (error) {
logger.error(`[${requestId}] Error deleting workflow MCP server:`, error)
return createMcpErrorResponse(toError(error), 'Failed to delete workflow MCP server', 500)
}
}
)
)
@@ -0,0 +1,184 @@
import { db } from '@sim/db'
import { workflowMcpServer, workflowMcpTool } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import { and, eq, isNull } from 'drizzle-orm'
import type { NextRequest } from 'next/server'
import {
updateWorkflowMcpToolBodySchema,
workflowMcpToolParamsSchema,
} from '@/lib/api/contracts/workflow-mcp-servers'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import {
mcpBodyReadErrorResponse,
readMcpJsonBodyWithLimit,
withMcpAuth,
} from '@/lib/mcp/middleware'
import { performDeleteWorkflowMcpTool, performUpdateWorkflowMcpTool } from '@/lib/mcp/orchestration'
import {
createMcpErrorResponse,
createMcpSuccessResponse,
mcpOrchestrationStatus,
} from '@/lib/mcp/utils'
const logger = createLogger('WorkflowMcpToolAPI')
export const dynamic = 'force-dynamic'
interface RouteParams {
id: string
toolId: string
}
/**
* GET - Get a specific tool
*/
export const GET = withRouteHandler(
withMcpAuth<RouteParams>('read')(
async (request: NextRequest, { userId, workspaceId, requestId }, { params }) => {
try {
const { id: serverId, toolId } = workflowMcpToolParamsSchema.parse(await params)
logger.info(`[${requestId}] Getting tool ${toolId} from server ${serverId}`)
const [server] = await db
.select({ id: workflowMcpServer.id })
.from(workflowMcpServer)
.where(
and(
eq(workflowMcpServer.id, serverId),
eq(workflowMcpServer.workspaceId, workspaceId),
isNull(workflowMcpServer.deletedAt)
)
)
.limit(1)
if (!server) {
return createMcpErrorResponse(new Error('Server not found'), 'Server not found', 404)
}
const [tool] = await db
.select()
.from(workflowMcpTool)
.where(
and(
eq(workflowMcpTool.id, toolId),
eq(workflowMcpTool.serverId, serverId),
isNull(workflowMcpTool.archivedAt)
)
)
.limit(1)
if (!tool) {
return createMcpErrorResponse(new Error('Tool not found'), 'Tool not found', 404)
}
return createMcpSuccessResponse({ tool })
} catch (error) {
logger.error(`[${requestId}] Error getting tool:`, error)
return createMcpErrorResponse(toError(error), 'Failed to get tool', 500)
}
}
)
)
/**
* PATCH - Update a tool's configuration
*/
export const PATCH = withRouteHandler(
withMcpAuth<RouteParams>('write')(
async (
request: NextRequest,
{ userId, userName, userEmail, workspaceId, requestId },
{ params }
) => {
try {
const { id: serverId, toolId } = workflowMcpToolParamsSchema.parse(await params)
const rawBody = await readMcpJsonBodyWithLimit(request)
const parsedBody = updateWorkflowMcpToolBodySchema.safeParse(rawBody)
if (!parsedBody.success) {
return createMcpErrorResponse(parsedBody.error, 'Invalid request format', 400)
}
const body = parsedBody.data
logger.info(`[${requestId}] Updating tool ${toolId} in server ${serverId}`)
const result = await performUpdateWorkflowMcpTool({
serverId,
toolId,
workspaceId,
userId,
actorName: userName,
actorEmail: userEmail,
toolName: body.toolName,
toolDescription: body.toolDescription,
parameterDescriptionOverrides: body.parameterDescriptionOverrides,
parameterSchema: body.parameterSchema,
})
if (!result.success || !result.tool) {
return createMcpErrorResponse(
new Error(result.error || 'Failed to update tool'),
result.error || 'Failed to update tool',
mcpOrchestrationStatus(result.errorCode)
)
}
const updatedTool = result.tool
logger.info(`[${requestId}] Successfully updated tool ${toolId}`)
return createMcpSuccessResponse({ tool: updatedTool })
} catch (error) {
const bodyErrorResponse = mcpBodyReadErrorResponse(error, request)
if (bodyErrorResponse) return bodyErrorResponse
logger.error(`[${requestId}] Error updating tool:`, error)
return createMcpErrorResponse(toError(error), 'Failed to update tool', 500)
}
}
)
)
/**
* DELETE - Remove a tool from an MCP server
*/
export const DELETE = withRouteHandler(
withMcpAuth<RouteParams>('write')(
async (
request: NextRequest,
{ userId, userName, userEmail, workspaceId, requestId },
{ params }
) => {
try {
const { id: serverId, toolId } = workflowMcpToolParamsSchema.parse(await params)
logger.info(`[${requestId}] Deleting tool ${toolId} from server ${serverId}`)
const result = await performDeleteWorkflowMcpTool({
serverId,
toolId,
workspaceId,
userId,
actorName: userName,
actorEmail: userEmail,
})
if (!result.success || !result.tool) {
return createMcpErrorResponse(
new Error(result.error || 'Tool not found'),
result.error || 'Tool not found',
mcpOrchestrationStatus(result.errorCode)
)
}
const deletedTool = result.tool
logger.info(`[${requestId}] Successfully deleted tool ${toolId}`)
return createMcpSuccessResponse({ message: `Tool ${toolId} deleted successfully` })
} catch (error) {
logger.error(`[${requestId}] Error deleting tool:`, error)
return createMcpErrorResponse(toError(error), 'Failed to delete tool', 500)
}
}
)
)
@@ -0,0 +1,152 @@
import { db } from '@sim/db'
import { workflow, workflowMcpServer, workflowMcpTool } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import { and, eq, isNull } from 'drizzle-orm'
import type { NextRequest } from 'next/server'
import {
createWorkflowMcpToolBodySchema,
workflowMcpServerParamsSchema,
} from '@/lib/api/contracts/workflow-mcp-servers'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import {
mcpBodyReadErrorResponse,
readMcpJsonBodyWithLimit,
withMcpAuth,
} from '@/lib/mcp/middleware'
import { performCreateWorkflowMcpTool } from '@/lib/mcp/orchestration'
import {
createMcpErrorResponse,
createMcpSuccessResponse,
mcpOrchestrationStatus,
} from '@/lib/mcp/utils'
const logger = createLogger('WorkflowMcpToolsAPI')
export const dynamic = 'force-dynamic'
interface RouteParams {
id: string
}
/**
* GET - List all tools for a workflow MCP server
*/
export const GET = withRouteHandler(
withMcpAuth<RouteParams>('read')(
async (request: NextRequest, { userId, workspaceId, requestId }, { params }) => {
try {
const { id: serverId } = workflowMcpServerParamsSchema.parse(await params)
logger.info(`[${requestId}] Listing tools for workflow MCP server: ${serverId}`)
const [server] = await db
.select({ id: workflowMcpServer.id })
.from(workflowMcpServer)
.where(
and(
eq(workflowMcpServer.id, serverId),
eq(workflowMcpServer.workspaceId, workspaceId),
isNull(workflowMcpServer.deletedAt)
)
)
.limit(1)
if (!server) {
return createMcpErrorResponse(new Error('Server not found'), 'Server not found', 404)
}
const tools = await db
.select({
id: workflowMcpTool.id,
serverId: workflowMcpTool.serverId,
workflowId: workflowMcpTool.workflowId,
toolName: workflowMcpTool.toolName,
toolDescription: workflowMcpTool.toolDescription,
parameterSchema: workflowMcpTool.parameterSchema,
parameterDescriptionOverrides: workflowMcpTool.parameterDescriptionOverrides,
createdAt: workflowMcpTool.createdAt,
updatedAt: workflowMcpTool.updatedAt,
workflowName: workflow.name,
workflowDescription: workflow.description,
isDeployed: workflow.isDeployed,
})
.from(workflowMcpTool)
.leftJoin(
workflow,
and(eq(workflowMcpTool.workflowId, workflow.id), isNull(workflow.archivedAt))
)
.where(and(eq(workflowMcpTool.serverId, serverId), isNull(workflowMcpTool.archivedAt)))
logger.info(`[${requestId}] Found ${tools.length} tools for server ${serverId}`)
return createMcpSuccessResponse({ tools })
} catch (error) {
logger.error(`[${requestId}] Error listing tools:`, error)
return createMcpErrorResponse(toError(error), 'Failed to list tools', 500)
}
}
)
)
/**
* POST - Add a workflow as a tool to an MCP server
*/
export const POST = withRouteHandler(
withMcpAuth<RouteParams>('write')(
async (
request: NextRequest,
{ userId, userName, userEmail, workspaceId, requestId },
{ params }
) => {
try {
const { id: serverId } = workflowMcpServerParamsSchema.parse(await params)
const rawBody = await readMcpJsonBodyWithLimit(request)
const parsedBody = createWorkflowMcpToolBodySchema.safeParse(rawBody)
if (!parsedBody.success) {
return createMcpErrorResponse(parsedBody.error, 'Invalid request format', 400)
}
const body = parsedBody.data
logger.info(`[${requestId}] Adding tool to workflow MCP server: ${serverId}`, {
workflowId: body.workflowId,
})
const result = await performCreateWorkflowMcpTool({
serverId,
workspaceId,
userId,
actorName: userName,
actorEmail: userEmail,
workflowId: body.workflowId,
toolName: body.toolName,
toolDescription: body.toolDescription,
parameterDescriptionOverrides: body.parameterDescriptionOverrides,
parameterSchema: body.parameterSchema,
})
if (!result.success || !result.tool) {
return createMcpErrorResponse(
new Error(result.error || 'Failed to add tool'),
result.error || 'Failed to add tool',
mcpOrchestrationStatus(result.errorCode)
)
}
const tool = result.tool
logger.info(
`[${requestId}] Successfully added tool ${tool.toolName} (workflow: ${body.workflowId}) to server ${serverId}`
)
return createMcpSuccessResponse({ tool }, 201)
} catch (error) {
const bodyErrorResponse = mcpBodyReadErrorResponse(error, request)
if (bodyErrorResponse) return bodyErrorResponse
logger.error(`[${requestId}] Error adding tool:`, error)
return createMcpErrorResponse(toError(error), 'Failed to add tool', 500)
}
}
)
)
@@ -0,0 +1,152 @@
import { db } from '@sim/db'
import { workflowMcpServer, workflowMcpTool } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import { and, eq, inArray, isNull, sql } from 'drizzle-orm'
import type { NextRequest } from 'next/server'
import { createWorkflowMcpServerBodySchema } from '@/lib/api/contracts/workflow-mcp-servers'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import {
mcpBodyReadErrorResponse,
readMcpJsonBodyWithLimit,
withMcpAuth,
} from '@/lib/mcp/middleware'
import { performCreateWorkflowMcpServer } from '@/lib/mcp/orchestration'
import {
createMcpErrorResponse,
createMcpSuccessResponse,
mcpOrchestrationStatus,
} from '@/lib/mcp/utils'
const logger = createLogger('WorkflowMcpServersAPI')
export const dynamic = 'force-dynamic'
/**
* GET - List all workflow MCP servers for the workspace
*/
export const GET = withRouteHandler(
withMcpAuth('read')(async (request: NextRequest, { userId, workspaceId, requestId }) => {
try {
logger.info(`[${requestId}] Listing workflow MCP servers for workspace ${workspaceId}`)
const servers = await db
.select({
id: workflowMcpServer.id,
workspaceId: workflowMcpServer.workspaceId,
createdBy: workflowMcpServer.createdBy,
name: workflowMcpServer.name,
description: workflowMcpServer.description,
isPublic: workflowMcpServer.isPublic,
createdAt: workflowMcpServer.createdAt,
updatedAt: workflowMcpServer.updatedAt,
toolCount: sql<number>`(
SELECT COUNT(*)::int
FROM "workflow_mcp_tool"
WHERE "workflow_mcp_tool"."server_id" = "workflow_mcp_server"."id"
AND "workflow_mcp_tool"."archived_at" IS NULL
)`.as('tool_count'),
})
.from(workflowMcpServer)
.where(
and(eq(workflowMcpServer.workspaceId, workspaceId), isNull(workflowMcpServer.deletedAt))
)
const serverIds = servers.map((s) => s.id)
const tools =
serverIds.length > 0
? await db
.select({
serverId: workflowMcpTool.serverId,
toolName: workflowMcpTool.toolName,
})
.from(workflowMcpTool)
.where(
and(
inArray(workflowMcpTool.serverId, serverIds),
isNull(workflowMcpTool.archivedAt)
)
)
: []
const toolNamesByServer: Record<string, string[]> = {}
for (const tool of tools) {
if (!toolNamesByServer[tool.serverId]) {
toolNamesByServer[tool.serverId] = []
}
toolNamesByServer[tool.serverId].push(tool.toolName)
}
const serversWithToolNames = servers.map((server) => ({
...server,
toolNames: toolNamesByServer[server.id] || [],
}))
logger.info(
`[${requestId}] Listed ${servers.length} workflow MCP servers for workspace ${workspaceId}`
)
return createMcpSuccessResponse({ servers: serversWithToolNames })
} catch (error) {
logger.error(`[${requestId}] Error listing workflow MCP servers:`, error)
return createMcpErrorResponse(toError(error), 'Failed to list workflow MCP servers', 500)
}
})
)
/**
* POST - Create a new workflow MCP server
*/
export const POST = withRouteHandler(
withMcpAuth('write')(
async (request: NextRequest, { userId, userName, userEmail, workspaceId, requestId }) => {
try {
const rawBody = await readMcpJsonBodyWithLimit(request)
const parsedBody = createWorkflowMcpServerBodySchema.safeParse(rawBody)
if (!parsedBody.success) {
return createMcpErrorResponse(parsedBody.error, 'Invalid request format', 400)
}
const body = parsedBody.data
logger.info(`[${requestId}] Creating workflow MCP server:`, {
name: body.name,
workspaceId,
workflowIds: body.workflowIds,
})
const result = await performCreateWorkflowMcpServer({
workspaceId,
userId,
actorName: userName,
actorEmail: userEmail,
name: body.name,
description: body.description,
isPublic: body.isPublic,
workflowIds: body.workflowIds,
})
if (!result.success || !result.server) {
return createMcpErrorResponse(
new Error(result.error || 'Failed to create workflow MCP server'),
result.error || 'Failed to create workflow MCP server',
mcpOrchestrationStatus(result.errorCode)
)
}
const { server } = result
const addedTools = result.addedTools || []
logger.info(
`[${requestId}] Successfully created workflow MCP server: ${body.name} (ID: ${server.id})`
)
return createMcpSuccessResponse({ server, addedTools }, 201)
} catch (error) {
const bodyErrorResponse = mcpBodyReadErrorResponse(error, request)
if (bodyErrorResponse) return bodyErrorResponse
logger.error(`[${requestId}] Error creating workflow MCP server:`, error)
return createMcpErrorResponse(toError(error), 'Failed to create workflow MCP server', 500)
}
}
)
)