d25d482dc2
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
955 lines
30 KiB
TypeScript
955 lines
30 KiB
TypeScript
/**
|
|
* 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)
|
|
})
|
|
})
|
|
})
|