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
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:
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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())
|
||||
@@ -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 }
|
||||
)
|
||||
}
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
}
|
||||
|
||||
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')
|
||||
})
|
||||
})
|
||||
@@ -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' })
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
)
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
Reference in New Issue
Block a user