Skip to content

Commit 01a172e

Browse files
fix(mcp-common): classify upstream 4xx errors correctly instead of returning 500 (#294)
* fix(mcp-common): classify upstream errors correctly instead of returning 500 Several error paths in mcp-common unconditionally throw McpError with status 500 regardless of the actual upstream HTTP status. This makes it impossible for clients to distinguish retriable server errors from actionable client errors like expired tokens or invalid grants. - Pass upstream 4xx status codes through with reportToSentry=false - Normalize upstream 5xx to 502 Bad Gateway with reportToSentry=true - Add safeStatusCode() helper to clamp status codes safely - Return OAuth 2.1 spec-compliant JSON error responses - Strip raw upstream error bodies from client-facing messages - Check response.ok before Zod parsing in getUserAndAccounts - Add 51 tests across 5 new spec files * refactor(mcp-common): align error handling with cloudflare-mcp patterns Extract shared helpers to DRY up repeated error classification: - throwUpstreamApiError() in mcp-error.ts for API failures - throwUpstreamTokenError() in cloudflare-auth.ts for token endpoints - throwCombinedApiError() for priority-based dual-endpoint failures Make getUserAndAccounts resilient: - Wrap Promise.all in try/catch for network errors (→ 502) - Use safeParse with graceful fallback instead of throwing .parse() - Surface accounts error when user returns unparseable 200 Fix OAuth 429 mapping to temporarily_unavailable (RFC 6749). Truncate raw upstream bodies in internalMessage to 500 chars. Add 12 new tests for combined failures, mixed-status priority, and malformed JSON edge cases (88 total). * style: fix prettier formatting * fix(mcp-common): update test config for node:fs and remove env mocks - Update compatibilityDate to 2026-03-05 for node:fs ReadStream support - Add DEV_DISABLE_OAUTH binding to vitest config instead of vi.mock - Remove vi.mock('cloudflare:workers') from cloudflare-api.spec.ts and sentry.spec.ts * fix(mcp-common): surface accounts errors, granular refresh mapping, truncate rawBody - Throw when /accounts fails even if /user succeeds (empty accounts is unusable) - Map refresh 429→temporarily_unavailable, 401→invalid_client instead of all-4xx→invalid_grant - Truncate rawBody to 500 chars in throwUpstreamApiError matching throwUpstreamTokenError * fix(mcp-common): mock cloudflare SDK in workerd tests, update vitest compat date The cloudflare SDK internally imports ReadStream from node:fs, which isn't available in workerd's vitest environment. Mock the SDK in the three affected test files. Update vitest compat date to 2026-03-09. * fix(mcp-common): use resolve.alias to mock cloudflare SDK in workerd tests vi.mock() doesn't prevent workerd from resolving the cloudflare SDK's transitive node:fs ReadStream import during module graph analysis. Use vitest resolve.alias to redirect 'cloudflare' to a lightweight mock module, avoiding the node:fs incompatibility entirely. * ci: use Node.js 24 only, drop matrix No longer publishing npm packages from this repo, so testing against multiple Node versions is unnecessary. * Apply suggestion from @mattzcarey * chore: add changeset for mcp-common error classification fix * fix(mcp-common): use GrantType enum and add requestedScope for updated oauth provider --------- Co-authored-by: Matt Carey <mcarey@cloudflare.com> Co-authored-by: Matt <77928207+mattzcarey@users.noreply.github.com>
1 parent af5f552 commit 01a172e

13 files changed

Lines changed: 1793 additions & 65 deletions
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@repo/mcp-common': patch
3+
---
4+
5+
Classify upstream 4xx errors correctly instead of returning 500, and set reportToSentry flag to avoid alerting on expected client errors
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
/**
2+
* Mock for the 'cloudflare' SDK package.
3+
* The real SDK transitively imports ReadStream from node:fs which is unavailable in workerd.
4+
* This mock is wired via resolve.alias in vitest.config.ts so the real module is never loaded.
5+
*/
6+
7+
export class Cloudflare {
8+
constructor(_opts?: Record<string, unknown>) {}
9+
}
10+
11+
export class APIError extends Error {
12+
status: number
13+
constructor(status: number, message?: string) {
14+
super(message)
15+
this.status = status
16+
this.name = 'APIError'
17+
}
18+
}
Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
import { fetchMock } from 'cloudflare:test'
2+
import { beforeAll, describe, expect, it } from 'vitest'
3+
4+
import { fetchCloudflareApi } from './cloudflare-api'
5+
import { McpError } from './mcp-error'
6+
7+
beforeAll(() => {
8+
fetchMock.activate()
9+
fetchMock.disableNetConnect()
10+
})
11+
12+
describe('fetchCloudflareApi', () => {
13+
const baseParams = {
14+
endpoint: '/workers/scripts',
15+
accountId: 'test-account-id',
16+
apiToken: 'test-api-token',
17+
}
18+
19+
it('returns parsed data on success', async () => {
20+
const responseData = { result: { id: 'test-script' } }
21+
fetchMock
22+
.get('https://api.cloudflare.com')
23+
.intercept({
24+
path: '/client/v4/accounts/test-account-id/workers/scripts',
25+
method: 'GET',
26+
})
27+
.reply(200, responseData)
28+
29+
const result = await fetchCloudflareApi(baseParams)
30+
expect(result).toEqual(responseData)
31+
})
32+
33+
it('throws McpError with status 404 for not found', async () => {
34+
fetchMock
35+
.get('https://api.cloudflare.com')
36+
.intercept({
37+
path: '/client/v4/accounts/test-account-id/workers/scripts',
38+
method: 'GET',
39+
})
40+
.reply(404, JSON.stringify({ errors: [{ message: 'Script not found' }] }))
41+
42+
try {
43+
await fetchCloudflareApi(baseParams)
44+
expect.unreachable()
45+
} catch (e) {
46+
expect(e).toBeInstanceOf(McpError)
47+
const err = e as McpError
48+
expect(err.code).toBe(404)
49+
expect(err.reportToSentry).toBe(false)
50+
expect(err.message).toBe('Cloudflare API request failed')
51+
expect(err.internalMessage).toContain('Script not found')
52+
}
53+
})
54+
55+
it('throws McpError with status 403 for forbidden', async () => {
56+
fetchMock
57+
.get('https://api.cloudflare.com')
58+
.intercept({
59+
path: '/client/v4/accounts/test-account-id/workers/scripts',
60+
method: 'GET',
61+
})
62+
.reply(403, JSON.stringify({ errors: [{ message: 'Forbidden' }] }))
63+
64+
try {
65+
await fetchCloudflareApi(baseParams)
66+
expect.unreachable()
67+
} catch (e) {
68+
expect(e).toBeInstanceOf(McpError)
69+
const err = e as McpError
70+
expect(err.code).toBe(403)
71+
expect(err.reportToSentry).toBe(false)
72+
}
73+
})
74+
75+
it('throws McpError with status 429 for rate limiting', async () => {
76+
fetchMock
77+
.get('https://api.cloudflare.com')
78+
.intercept({
79+
path: '/client/v4/accounts/test-account-id/workers/scripts',
80+
method: 'GET',
81+
})
82+
.reply(429, JSON.stringify({ errors: [{ message: 'Rate limited' }] }))
83+
84+
try {
85+
await fetchCloudflareApi(baseParams)
86+
expect.unreachable()
87+
} catch (e) {
88+
expect(e).toBeInstanceOf(McpError)
89+
const err = e as McpError
90+
expect(err.code).toBe(429)
91+
expect(err.reportToSentry).toBe(false)
92+
}
93+
})
94+
95+
it('throws McpError with status 502 for upstream 500 (bad gateway)', async () => {
96+
fetchMock
97+
.get('https://api.cloudflare.com')
98+
.intercept({
99+
path: '/client/v4/accounts/test-account-id/workers/scripts',
100+
method: 'GET',
101+
})
102+
.reply(500, 'Internal Server Error')
103+
104+
try {
105+
await fetchCloudflareApi(baseParams)
106+
expect.unreachable()
107+
} catch (e) {
108+
expect(e).toBeInstanceOf(McpError)
109+
const err = e as McpError
110+
expect(err.code).toBe(502)
111+
expect(err.message).toBe('Upstream Cloudflare API unavailable')
112+
expect(err.reportToSentry).toBe(true)
113+
expect(err.internalMessage).toContain('Cloudflare API 500')
114+
}
115+
})
116+
117+
it('throws McpError with status 502 for upstream 502', async () => {
118+
fetchMock
119+
.get('https://api.cloudflare.com')
120+
.intercept({
121+
path: '/client/v4/accounts/test-account-id/workers/scripts',
122+
method: 'GET',
123+
})
124+
.reply(502, 'Bad Gateway')
125+
126+
try {
127+
await fetchCloudflareApi(baseParams)
128+
expect.unreachable()
129+
} catch (e) {
130+
expect(e).toBeInstanceOf(McpError)
131+
const err = e as McpError
132+
expect(err.code).toBe(502)
133+
expect(err.message).toBe('Upstream Cloudflare API unavailable')
134+
expect(err.reportToSentry).toBe(true)
135+
expect(err.internalMessage).toContain('Cloudflare API 502')
136+
}
137+
})
138+
139+
it('preserves error text in internalMessage (not user-facing message)', async () => {
140+
const errorBody = '{"errors":[{"message":"Worker not found","code":10007}]}'
141+
fetchMock
142+
.get('https://api.cloudflare.com')
143+
.intercept({
144+
path: '/client/v4/accounts/test-account-id/workers/scripts',
145+
method: 'GET',
146+
})
147+
.reply(404, errorBody)
148+
149+
try {
150+
await fetchCloudflareApi(baseParams)
151+
expect.unreachable()
152+
} catch (e) {
153+
expect(e).toBeInstanceOf(McpError)
154+
const err = e as McpError
155+
expect(err.message).toBe('Cloudflare API request failed')
156+
expect(err.internalMessage).toContain('Worker not found')
157+
}
158+
})
159+
})

packages/mcp-common/src/cloudflare-api.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import { Cloudflare } from 'cloudflare'
22
import { env } from 'cloudflare:workers'
33

4+
import { throwUpstreamApiError } from './mcp-error'
5+
46
import type { z } from 'zod'
57

68
export function getCloudflareClient(apiToken: string) {
@@ -55,8 +57,7 @@ export async function fetchCloudflareApi<T>({
5557
})
5658

5759
if (!response.ok) {
58-
const error = await response.text()
59-
throw new Error(`Cloudflare API request failed: ${error}`)
60+
throwUpstreamApiError(response.status, 'Cloudflare API', await response.text())
6061
}
6162

6263
const data = await response.json()

0 commit comments

Comments
 (0)