Skip to content

Commit b1d0cfd

Browse files
committed
fix(workflows): keep long-running calls active
1 parent 651586e commit b1d0cfd

22 files changed

Lines changed: 1121 additions & 301 deletions

File tree

apps/docs/openapi-v2-workflows.json

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2502,7 +2502,7 @@
25022502
"post": {
25032503
"operationId": "executeWorkflowV2",
25042504
"summary": "Execute Workflow",
2505-
"description": "Execute the deployment; `run.source: \"manual\"` uses draft state. Manual runs require a personal key or OAuth write access; workspace keys, anonymous callers, and async are rejected. Start at a runnable trigger, or resume from `sourceRunId` using the same-workflow snapshot. Public deployments allow anonymous sync or streaming; async requires credentials. Sync timeouts return `200` with failed status and `TIMEOUT`. Each option carries the modes it requires and the modes that reject it; a violated combination is a 400.\n\nOAuth scope: `api:write`.",
2505+
"description": "Execute a deployment or use `run.source: \"manual\"` for draft state. Manual runs require personal or OAuth write access and reject workspace keys, anonymous callers, and async. Start at a trigger or resume from same-workflow `sourceRunId`. Public deployments allow anonymous sync or streaming. Request `application/x-ndjson` for 15-second heartbeats and final resource. Timeouts return `200` with failed status and `TIMEOUT`. Each option carries the modes it requires and the modes that reject it; a violated combination is a 400.\n\nOAuth scope: `api:write`.",
25062506
"x-sim-operation": "workflows.execute",
25072507
"x-oauth-scope": "api:write",
25082508
"tags": ["Workflows"],
@@ -2566,7 +2566,7 @@
25662566
},
25672567
"responses": {
25682568
"200": {
2569-
"description": "A synchronous run result or Server-Sent Event stream.",
2569+
"description": "A synchronous run result, heartbeat-delimited NDJSON result stream, or Server-Sent Event stream.",
25702570
"headers": {
25712571
"X-Run-Id": {
25722572
"$ref": "#/components/headers/X-Run-Id"
@@ -2587,6 +2587,11 @@
25872587
"$ref": "#/components/schemas/ExecuteWorkflowSyncResponse"
25882588
}
25892589
},
2590+
"application/x-ndjson": {
2591+
"schema": {
2592+
"type": "string"
2593+
}
2594+
},
25902595
"text/event-stream": {
25912596
"schema": {
25922597
"type": "string"

apps/sim/app/api/mcp/serve/[serverId]/route.test.ts

Lines changed: 125 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,7 @@ vi.mock('@/lib/auth/internal', () => ({
9595
}))
9696

9797
vi.mock('@/lib/core/execution-limits', () => ({
98-
getMaxExecutionTimeout: () => 10_000,
98+
getMaxExecutionTimeout: () => 60_000,
9999
}))
100100

101101
vi.mock('@/lib/workflows/executor/execute-service', () => ({
@@ -338,6 +338,130 @@ describe('MCP Serve Route', () => {
338338
})
339339
})
340340

341+
it('keeps a Streamable HTTP tool call active and ends with its JSON-RPC response', async () => {
342+
vi.useFakeTimers()
343+
try {
344+
dbChainMockFns.limit
345+
.mockResolvedValueOnce([
346+
{
347+
id: 'server-1',
348+
name: 'Public Server',
349+
workspaceId: 'ws-1',
350+
isPublic: true,
351+
createdBy: 'owner-1',
352+
},
353+
])
354+
.mockResolvedValueOnce([{ toolName: 'tool_a', workflowId: 'wf-1' }])
355+
.mockResolvedValueOnce([{ workspaceId: 'ws-1', deploymentVersionId: 'deployment-1' }])
356+
357+
let finishExecution!: (result: unknown) => void
358+
mockExecuteWorkflowService.mockReturnValueOnce(
359+
new Promise((resolve) => {
360+
finishExecution = resolve
361+
})
362+
)
363+
364+
const req = new NextRequest('http://localhost:3000/api/mcp/serve/server-1', {
365+
method: 'POST',
366+
headers: { accept: 'application/json, text/event-stream' },
367+
body: JSON.stringify({
368+
jsonrpc: '2.0',
369+
id: 1,
370+
method: 'tools/call',
371+
params: { name: 'tool_a', arguments: { q: 'test' } },
372+
}),
373+
})
374+
const response = await POST(req, { params: Promise.resolve({ serverId: 'server-1' }) })
375+
376+
expect(response.status).toBe(200)
377+
expect(response.headers.get('content-type')).toContain('text/event-stream')
378+
if (!response.body) throw new Error('Expected MCP event stream')
379+
const reader = response.body.getReader()
380+
const decoder = new TextDecoder()
381+
expect(decoder.decode((await reader.read()).value)).toBe(': keepalive\n\n')
382+
await vi.advanceTimersByTimeAsync(15_000)
383+
expect(decoder.decode((await reader.read()).value)).toBe(': keepalive\n\n')
384+
385+
finishExecution({
386+
ok: true,
387+
executionId: 'exec-1',
388+
workflowId: 'wf-1',
389+
status: 'completed',
390+
aborted: null,
391+
output: { ok: true },
392+
error: null,
393+
hasResponseBlock: false,
394+
resolvedSecretTraceProvenance: createResolvedSecretTraceProvenance('owner-1'),
395+
})
396+
397+
const event = decoder.decode((await reader.read()).value)
398+
expect(JSON.parse(event.replace(/^data: /, '').trim())).toMatchObject({
399+
jsonrpc: '2.0',
400+
id: 1,
401+
result: { content: [{ type: 'text' }], isError: false },
402+
})
403+
expect((await reader.read()).done).toBe(true)
404+
} finally {
405+
vi.useRealTimers()
406+
}
407+
})
408+
409+
it('cancels the workflow when an MCP event-stream consumer disconnects', async () => {
410+
dbChainMockFns.limit
411+
.mockResolvedValueOnce([
412+
{
413+
id: 'server-1',
414+
name: 'Public Server',
415+
workspaceId: 'ws-1',
416+
isPublic: true,
417+
createdBy: 'owner-1',
418+
},
419+
])
420+
.mockResolvedValueOnce([{ toolName: 'tool_a', workflowId: 'wf-1' }])
421+
.mockResolvedValueOnce([{ workspaceId: 'ws-1', deploymentVersionId: 'deployment-1' }])
422+
423+
let executionSignal: AbortSignal | undefined
424+
mockExecuteWorkflowService.mockImplementationOnce(
425+
({ abortSignal }: { abortSignal: AbortSignal }) =>
426+
new Promise((resolve) => {
427+
executionSignal = abortSignal
428+
const finish = () =>
429+
resolve({
430+
ok: true,
431+
executionId: 'exec-1',
432+
workflowId: 'wf-1',
433+
status: 'cancelled',
434+
aborted: 'client',
435+
output: undefined,
436+
error: { message: 'Client cancelled request', code: 'CANCELLED' },
437+
hasResponseBlock: false,
438+
})
439+
if (abortSignal.aborted) finish()
440+
else abortSignal.addEventListener('abort', finish, { once: true })
441+
})
442+
)
443+
444+
const req = new NextRequest('http://localhost:3000/api/mcp/serve/server-1', {
445+
method: 'POST',
446+
headers: { accept: 'application/json, text/event-stream' },
447+
body: JSON.stringify({
448+
jsonrpc: '2.0',
449+
id: 1,
450+
method: 'tools/call',
451+
params: { name: 'tool_a', arguments: { q: 'test' } },
452+
}),
453+
})
454+
const response = await POST(req, { params: Promise.resolve({ serverId: 'server-1' }) })
455+
if (!response.body) throw new Error('Expected MCP event stream')
456+
const reader = response.body.getReader()
457+
await reader.read()
458+
await vi.waitFor(() => expect(executionSignal).toBeDefined())
459+
460+
await reader.cancel('client disconnected')
461+
462+
expect(executionSignal?.aborted).toBe(true)
463+
})
464+
341465
it('rejects a personal api key when the workspace disallows personal api keys', async () => {
342466
dbChainMockFns.limit.mockResolvedValueOnce([
343467
{

apps/sim/app/api/mcp/serve/[serverId]/route.ts

Lines changed: 111 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import {
2727
workspace,
2828
} from '@sim/db/schema'
2929
import { createLogger } from '@sim/logger'
30+
import { getErrorMessage } from '@sim/utils/errors'
3031
import { isRecordLike } from '@sim/utils/object'
3132
import { and, asc, eq, gt, isNull, sql } from 'drizzle-orm'
3233
import { type NextRequest, NextResponse } from 'next/server'
@@ -45,6 +46,7 @@ import {
4546
} from '@/lib/billing/core/billing-attribution'
4647
import { getMaxExecutionTimeout } from '@/lib/core/execution-limits'
4748
import { generateRequestId } from '@/lib/core/utils/request'
49+
import { encodeSSE, encodeSSEComment, SSE_HEADERS } from '@/lib/core/utils/sse'
4850
import {
4951
assertContentLengthWithinLimit,
5052
assertKnownSizeWithinLimit,
@@ -75,6 +77,7 @@ const MAX_MCP_WORKFLOW_REQUEST_BYTES = 10 * 1024 * 1024
7577
const MAX_MCP_TOOL_RESULT_TEXT_BYTES = 10 * 1024 * 1024
7678
const MAX_MCP_TOOLS_LIST_COUNT = MAX_MCP_TOOLS_PER_SERVER
7779
const MAX_MCP_TOOLS_LIST_SCHEMA_BYTES = MAX_MCP_PARAMETER_SCHEMA_BYTES
80+
const MCP_STREAM_KEEPALIVE_INTERVAL_MS = 15_000
7881
const MB = 1024 * 1024
7982

8083
function negotiateProtocolVersion(rpcParams: unknown): string {
@@ -137,6 +140,95 @@ function callerAbortedJsonRpcResponse(
137140
return abortSignal?.isCallerAborted() ? clientCancelledJsonRpcResponse(id) : null
138141
}
139142

143+
function acceptsEventStream(request: NextRequest): boolean {
144+
return request.headers.get('accept')?.includes('text/event-stream') === true
145+
}
146+
147+
/**
148+
* Sends a Streamable HTTP response as SSE so a long tool call can keep the
149+
* connection active before its terminal JSON-RPC message is available.
150+
*/
151+
function streamJsonRpcResponse(
152+
id: RequestId,
153+
requestSignal: AbortSignal,
154+
run: (signal: AbortSignal) => Promise<NextResponse>
155+
): Response {
156+
const executionController = new AbortController()
157+
let cancelled = false
158+
let keepaliveId: ReturnType<typeof setInterval> | undefined
159+
160+
const stopKeepalive = () => {
161+
if (keepaliveId) {
162+
clearInterval(keepaliveId)
163+
keepaliveId = undefined
164+
}
165+
}
166+
const abortExecution = (reason?: unknown) => {
167+
if (!executionController.signal.aborted) {
168+
executionController.abort(reason ?? new Error('MCP client disconnected'))
169+
}
170+
}
171+
const abortFromRequest = () => abortExecution(requestSignal.reason)
172+
173+
if (requestSignal.aborted) {
174+
abortFromRequest()
175+
} else {
176+
requestSignal.addEventListener('abort', abortFromRequest, { once: true })
177+
}
178+
179+
const stream = new ReadableStream<Uint8Array>({
180+
start(controller) {
181+
const send = (chunk: Uint8Array): boolean => {
182+
if (cancelled) return false
183+
try {
184+
controller.enqueue(chunk)
185+
return true
186+
} catch {
187+
cancelled = true
188+
stopKeepalive()
189+
abortExecution()
190+
return false
191+
}
192+
}
193+
194+
if (send(encodeSSEComment('keepalive'))) {
195+
keepaliveId = setInterval(() => {
196+
send(encodeSSEComment('keepalive'))
197+
}, MCP_STREAM_KEEPALIVE_INTERVAL_MS)
198+
}
199+
200+
void run(executionController.signal)
201+
.then(async (response) => {
202+
const message: unknown = await response.json()
203+
send(encodeSSE(message))
204+
})
205+
.catch((error) => {
206+
logger.error('MCP response stream failed', { error: getErrorMessage(error) })
207+
send(encodeSSE(createError(id, ErrorCode.InternalError, 'Internal error')))
208+
})
209+
.finally(() => {
210+
stopKeepalive()
211+
requestSignal.removeEventListener('abort', abortFromRequest)
212+
if (!cancelled) controller.close()
213+
})
214+
},
215+
cancel(reason) {
216+
cancelled = true
217+
stopKeepalive()
218+
requestSignal.removeEventListener('abort', abortFromRequest)
219+
abortExecution(reason)
220+
},
221+
})
222+
223+
return new Response(stream, {
224+
headers: {
225+
...SSE_HEADERS,
226+
'Cache-Control': 'no-cache, no-transform',
227+
Vary: 'Accept',
228+
},
229+
})
230+
}
231+
140232
function limitMessage(label: string, maxBytes: number): string {
141233
return `${label} exceeds maximum size of ${Math.round(maxBytes / MB)}MB`
142234
}
@@ -406,12 +498,12 @@ async function authorizeMcpServeRequest(
406498
}
407499
}
408500

409-
function unsupportedSseTransportResponse(): NextResponse {
501+
function unsupportedSseGetResponse(): NextResponse {
410502
return NextResponse.json(
411503
{
412504
error: {
413505
code: 'unsupported_transport',
414-
message: 'SSE transport is not supported for workflow MCP servers',
506+
message: 'Standalone SSE GET transport is not supported for workflow MCP servers',
415507
supportedTransports: ['streamable-http'],
416508
allowedMethods: ['GET', 'POST', 'DELETE'],
417509
},
@@ -439,7 +531,7 @@ export const GET = withRouteHandler(
439531
if (authResult.response) return authResult.response
440532

441533
if (request.headers.get('accept')?.includes('text/event-stream')) {
442-
return unsupportedSseTransportResponse()
534+
return unsupportedSseGetResponse()
443535
}
444536

445537
return NextResponse.json({
@@ -557,16 +649,22 @@ export const POST = withRouteHandler(
557649
)
558650
}
559651

560-
return handleToolsCall(
561-
id,
562-
serverId,
563-
server.workspaceId,
564-
paramsValidation.data,
565-
executeAuthContext,
566-
server.isPublic ? server.createdBy : undefined,
567-
request.headers.get(SIM_VIA_HEADER),
568-
request.signal
569-
)
652+
const callTool = (signal: AbortSignal) =>
653+
handleToolsCall(
654+
id,
655+
serverId,
656+
server.workspaceId,
657+
paramsValidation.data,
658+
executeAuthContext,
659+
server.isPublic ? server.createdBy : undefined,
660+
request.headers.get(SIM_VIA_HEADER),
661+
signal
662+
)
663+
664+
if (acceptsEventStream(request)) {
665+
return streamJsonRpcResponse(id, request.signal, callTool)
666+
}
667+
return callTool(request.signal)
570668
}
571669

572670
default:

0 commit comments

Comments
 (0)