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
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:
@@ -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 })
|
||||
}
|
||||
}
|
||||
)
|
||||
Reference in New Issue
Block a user