chore: import upstream snapshot with attribution
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) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:20:55 +08:00
commit d25d482dc2
13754 changed files with 4996608 additions and 0 deletions
@@ -0,0 +1,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 })
}
}
)