Skip to content

Commit ed71881

Browse files
committed
fix(workflows): preserve stream compatibility
1 parent 0aaeae2 commit ed71881

10 files changed

Lines changed: 173 additions & 20 deletions

File tree

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

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -309,7 +309,10 @@ describe('MCP Serve Route', () => {
309309

310310
const req = new NextRequest('http://localhost:3000/api/mcp/serve/server-1', {
311311
method: 'POST',
312-
headers: { 'X-API-Key': 'pk_test_123' },
312+
headers: {
313+
'X-API-Key': 'pk_test_123',
314+
Accept: 'application/json, text/event-stream;q=0',
315+
},
313316
body: JSON.stringify({
314317
jsonrpc: '2.0',
315318
id: 1,
@@ -320,6 +323,7 @@ describe('MCP Serve Route', () => {
320323
const response = await POST(req, { params: Promise.resolve({ serverId: 'server-1' }) })
321324

322325
expect(response.status).toBe(200)
326+
expect(response.headers.get('content-type')).toContain('application/json')
323327
expect(mockExecuteWorkflowService).toHaveBeenCalledTimes(1)
324328
expect(mockExecuteWorkflowService).toHaveBeenCalledWith(
325329
expect.objectContaining({
@@ -406,6 +410,29 @@ describe('MCP Serve Route', () => {
406410
}
407411
})
408412

413+
it('serves metadata when standalone SSE GET is explicitly rejected', async () => {
414+
dbChainMockFns.limit.mockResolvedValueOnce([
415+
{
416+
id: 'server-1',
417+
name: 'Public Server',
418+
workspaceId: 'ws-1',
419+
isPublic: true,
420+
createdBy: 'owner-1',
421+
},
422+
])
423+
424+
const request = new NextRequest('http://localhost:3000/api/mcp/serve/server-1', {
425+
headers: { accept: 'application/json, text/event-stream;q=0' },
426+
})
427+
const response = await GET(request, { params: Promise.resolve({ serverId: 'server-1' }) })
428+
429+
expect(response.status).toBe(200)
430+
expect(await response.json()).toMatchObject({
431+
name: 'Public Server',
432+
capabilities: { tools: {} },
433+
})
434+
})
435+
409436
it('cancels the workflow when an MCP event-stream consumer disconnects', async () => {
410437
dbChainMockFns.limit
411438
.mockResolvedValueOnce([

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

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ import {
4545
resolveBillingAttribution,
4646
} from '@/lib/billing/core/billing-attribution'
4747
import { getMaxExecutionTimeout } from '@/lib/core/execution-limits'
48+
import { acceptsMediaType } from '@/lib/core/utils/media-types'
4849
import { generateRequestId } from '@/lib/core/utils/request'
4950
import { encodeSSE, encodeSSEComment, SSE_HEADERS } from '@/lib/core/utils/sse'
5051
import {
@@ -141,7 +142,7 @@ function callerAbortedJsonRpcResponse(
141142
}
142143

143144
function acceptsEventStream(request: NextRequest): boolean {
144-
return request.headers.get('accept')?.includes('text/event-stream') === true
145+
return acceptsMediaType(request.headers.get('accept'), 'text/event-stream')
145146
}
146147

147148
/**
@@ -530,7 +531,7 @@ export const GET = withRouteHandler(
530531
const authResult = await authorizeMcpServeRequest(request, server)
531532
if (authResult.response) return authResult.response
532533

533-
if (request.headers.get('accept')?.includes('text/event-stream')) {
534+
if (acceptsEventStream(request)) {
534535
return unsupportedSseGetResponse()
535536
}
536537

apps/sim/app/api/mothership/execute/route.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import { requestExplicitStreamAbort } from '@/lib/copilot/request/session/explic
2424
import type { StreamEvent } from '@/lib/copilot/request/types'
2525
import { normalizeSecretMountPolicy } from '@/lib/copilot/secret-mount-policy'
2626
import { isDocSandboxEnabled } from '@/lib/core/config/env-flags'
27+
import { acceptsMediaType } from '@/lib/core/utils/media-types'
2728
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
2829
import { getPersonalAndWorkspaceEnv } from '@/lib/environment/utils'
2930
import {
@@ -82,7 +83,7 @@ function isAbortError(error: unknown): boolean {
8283
function wantsStreamedExecuteResponse(req: NextRequest): boolean {
8384
return (
8485
req.headers.get(MOTHERSHIP_EXECUTE_STREAM_HEADER) === MOTHERSHIP_EXECUTE_STREAM_VALUE ||
85-
req.headers.get('accept')?.includes(MOTHERSHIP_EXECUTE_STREAM_CONTENT_TYPE) === true
86+
acceptsMediaType(req.headers.get('accept'), MOTHERSHIP_EXECUTE_STREAM_CONTENT_TYPE)
8687
)
8788
}
8889

apps/sim/app/api/v2/chat/route.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ import {
4949
type WorkspaceAuthorizationContext,
5050
} from '@/lib/core/application'
5151
import { isDocSandboxEnabled } from '@/lib/core/config/env-flags'
52+
import { acceptsMediaType } from '@/lib/core/utils/media-types'
5253
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
5354
import { getPersonalAndWorkspaceEnv } from '@/lib/environment/utils'
5455
import { CAPABILITY_RULES } from '@/lib/permission-groups/capabilities'
@@ -128,7 +129,7 @@ function isAbortError(error: unknown): boolean {
128129
function wantsStreamedChatResponse(req: NextRequest): boolean {
129130
return (
130131
req.headers.get(CHAT_STREAM_HEADER) === CHAT_STREAM_VALUE ||
131-
req.headers.get('accept')?.includes(CHAT_STREAM_CONTENT_TYPE) === true
132+
acceptsMediaType(req.headers.get('accept'), CHAT_STREAM_CONTENT_TYPE)
132133
)
133134
}
134135

apps/sim/app/api/v2/workflows/[workflowId]/execute/route.test.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -421,6 +421,18 @@ describe('POST /api/v2/workflows/[workflowId]/execute', () => {
421421
}
422422
})
423423

424+
it('keeps the JSON response when NDJSON is explicitly rejected', async () => {
425+
const response = await callExecute(
426+
{ input: { hello: 'world' } },
427+
{ Accept: 'application/json, application/x-ndjson;q=0' }
428+
)
429+
430+
expect(response.headers.get('content-type')).toContain('application/json')
431+
expect(await response.json()).toMatchObject({
432+
data: { runId: 'execution-123', status: 'completed' },
433+
})
434+
})
435+
424436
it('uses the heartbeat result transport for a manual draft run', async () => {
425437
authenticatePersonalKey()
426438
let finishExecution!: (result: unknown) => void

apps/sim/app/api/v2/workflows/[workflowId]/execute/route.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import { getWorkspaceBilledAccountUserId } from '@/lib/billing/core/billing-attr
2525
import { tryAdmit } from '@/lib/core/admission/gate'
2626
import { ADMISSION_ERROR_DESCRIPTOR } from '@/lib/core/admission/transient-failure'
2727
import type { ForbiddenDetailCode } from '@/lib/core/application'
28+
import { acceptsMediaType } from '@/lib/core/utils/media-types'
2829
import { generateRequestId } from '@/lib/core/utils/request'
2930
import { getBaseUrl } from '@/lib/core/utils/urls'
3031
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
@@ -110,7 +111,7 @@ function serviceFailureResponse(failure: ExecuteWorkflowServiceFailure) {
110111
}
111112

112113
function wantsResultStream(req: NextRequest): boolean {
113-
return req.headers.get('accept')?.includes(WORKFLOW_RESULT_STREAM_CONTENT_TYPE) === true
114+
return acceptsMediaType(req.headers.get('accept'), WORKFLOW_RESULT_STREAM_CONTENT_TYPE)
114115
}
115116

116117
function encodeNdjson(value: unknown): Uint8Array {
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { describe, expect, it } from 'vitest'
2+
import { acceptsMediaType } from '@/lib/core/utils/media-types'
3+
4+
describe('acceptsMediaType', () => {
5+
it.each([
6+
['application/json, application/x-ndjson', true],
7+
['application/x-ndjson; q=0.5', true],
8+
['Application/X-Ndjson;Q=1.000', true],
9+
['application/json, application/x-ndjson;q=0', false],
10+
['application/x-ndjson;q=0.000', false],
11+
['application/x-ndjson;q=1.1', false],
12+
['application/x-ndjson;q=invalid', false],
13+
['application/x-ndjson;q=1;q=0', false],
14+
['application/x-ndjson;profile="one,two";q=0', false],
15+
['application/x-ndjson;profile="one;two";q=0.5', true],
16+
['application/x-ndjson;profile="unterminated;q=0', false],
17+
['application/json', false],
18+
['*/*', false],
19+
['', false],
20+
])('parses %s', (header, expected) => {
21+
expect(acceptsMediaType(header, 'application/x-ndjson')).toBe(expected)
22+
})
23+
24+
it('rejects a missing Accept header', () => {
25+
expect(acceptsMediaType(null, 'application/x-ndjson')).toBe(false)
26+
})
27+
})
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
const QUALITY_VALUE = /^(?:0(?:\.\d{0,3})?|1(?:\.0{0,3})?)$/
2+
3+
/** Splits an HTTP list or parameter list without cutting inside quoted strings. */
4+
function splitOutsideQuotes(value: string, separator: ',' | ';'): string[] | null {
5+
const parts: string[] = []
6+
let start = 0
7+
let quoted = false
8+
let escaped = false
9+
10+
for (let index = 0; index < value.length; index++) {
11+
const character = value[index]
12+
if (escaped) {
13+
escaped = false
14+
continue
15+
}
16+
if (quoted && character === '\\') {
17+
escaped = true
18+
continue
19+
}
20+
if (character === '"') {
21+
quoted = !quoted
22+
continue
23+
}
24+
if (!quoted && character === separator) {
25+
parts.push(value.slice(start, index))
26+
start = index + 1
27+
}
28+
}
29+
30+
if (quoted || escaped) return null
31+
parts.push(value.slice(start))
32+
return parts
33+
}
34+
35+
/**
36+
* Whether an Accept header explicitly permits a media type.
37+
*
38+
* Wildcards do not opt callers into a streaming protocol, and a matching range
39+
* with an invalid or zero quality value is not acceptable.
40+
*/
41+
export function acceptsMediaType(acceptHeader: string | null, mediaType: string): boolean {
42+
if (!acceptHeader) return false
43+
const normalizedMediaType = mediaType.trim().toLowerCase()
44+
const ranges = splitOutsideQuotes(acceptHeader, ',')
45+
if (!ranges) return false
46+
47+
return ranges.some((range) => {
48+
const parts = splitOutsideQuotes(range, ';')
49+
if (!parts) return false
50+
const [type, ...parameters] = parts
51+
if (type.trim().toLowerCase() !== normalizedMediaType) return false
52+
53+
const qualityParameters = parameters.filter((parameter) => {
54+
const separator = parameter.indexOf('=')
55+
const name = separator === -1 ? parameter : parameter.slice(0, separator)
56+
return name.trim().toLowerCase() === 'q'
57+
})
58+
if (qualityParameters.length === 0) return true
59+
if (qualityParameters.length > 1) return false
60+
61+
const qualityParameter = qualityParameters[0]
62+
const separator = qualityParameter.indexOf('=')
63+
const quality = separator === -1 ? '' : qualityParameter.slice(separator + 1).trim()
64+
return QUALITY_VALUE.test(quality) && Number(quality) > 0
65+
})
66+
}

packages/sim-cli/src/commands/protocol/workflow-run-follow.test.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -358,6 +358,16 @@ describe('sim workflows run --follow', () => {
358358
})
359359
})
360360

361+
it('translates API field names in synchronous run errors', async () => {
362+
requestRaw.mockRejectedValue(
363+
new SimApiError('executionTimeoutSeconds must be less than or equal to 3000', 400)
364+
)
365+
366+
await expect(run(WORKFLOW_ID)).rejects.toThrow(
367+
'--execution-timeout-seconds must be less than or equal to 3000'
368+
)
369+
})
370+
361371
it('keeps async runs on the generated JSON path', async () => {
362372
request.mockResolvedValue({ data: { runId: 'run-1', statusUrl: '/runs/run-1' } })
363373
vi.spyOn(console, 'log').mockImplementation(() => {})

packages/sim-cli/src/commands/protocol/workflow-run-follow.ts

Lines changed: 21 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { SimApiError } from '../../http/client'
77
import { readNdjson } from '../../http/ndjson'
88
import { safeOneLine, sanitize } from '../../output/render'
99
import { executeOperation, runFailureMessage } from '../../runtime/execute'
10+
import { retypeApiError } from '../../runtime/naming'
1011
import { buildRequest } from '../../runtime/request'
1112
import { renderResult } from '../../runtime/result'
1213
import type { OperationSpec } from '../../runtime/types'
@@ -154,21 +155,27 @@ async function runWithResultStream(workflowId: string, command: Command): Promis
154155
const flags = command.optsWithGlobals() as Record<string, unknown>
155156
const { client, profile } = clientFrom(command)
156157
const operation = V2_OPERATIONS.executeWorkflow as OperationSpec
157-
const request = buildRequest('executeWorkflow', [workflowId], flags, profile.workspaceId)
158-
const response = await client.requestRaw(request.path, {
159-
method: operation.method,
160-
query: request.query,
161-
body: request.body,
162-
headers: { ...request.headers, accept: WORKFLOW_RESULT_STREAM_CONTENT_TYPE },
163-
})
164-
const payload = await readWorkflowResult(response)
158+
const commandSpec = CLI_CONTRACT.executeWorkflow ?? {}
165159

166-
renderResult('executeWorkflow', profile.output, payload, CLI_CONTRACT.executeWorkflow ?? {}, {
167-
expandedTrace: flags.trace === true,
168-
})
169-
170-
const failure = runFailureMessage('executeWorkflow', payload)
171-
if (failure) throw new SimApiError(failure, 0)
160+
try {
161+
const request = buildRequest('executeWorkflow', [workflowId], flags, profile.workspaceId)
162+
const response = await client.requestRaw(request.path, {
163+
method: operation.method,
164+
query: request.query,
165+
body: request.body,
166+
headers: { ...request.headers, accept: WORKFLOW_RESULT_STREAM_CONTENT_TYPE },
167+
})
168+
const payload = await readWorkflowResult(response)
169+
170+
renderResult('executeWorkflow', profile.output, payload, commandSpec, {
171+
expandedTrace: flags.trace === true,
172+
})
173+
174+
const failure = runFailureMessage('executeWorkflow', payload)
175+
if (failure) throw new SimApiError(failure, 0)
176+
} catch (error) {
177+
throw retypeApiError(error, 'executeWorkflow', commandSpec, operation)
178+
}
172179
}
173180

174181
/**

0 commit comments

Comments
 (0)