Skip to content

Commit 542d5c9

Browse files
v2: SdkError tests + codemod (modelcontextprotocol#2137)
1 parent f563440 commit 542d5c9

9 files changed

Lines changed: 101 additions & 23 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@modelcontextprotocol/codemod': patch
3+
---
4+
5+
The v1→v2 codemod now migrates the removed `StreamableHTTPError` to `SdkHttpError` (instead of the base `SdkError`), matching the shipped error type and the migration guide. Diagnostics now point at the typed `error.status` / `error.statusText` accessors and note that unexpected-content-type responses are thrown as the base `SdkError`.

packages/client/test/client/sse.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1590,6 +1590,7 @@ describe('SSEClientTransport', () => {
15901590
expect(error).toBeInstanceOf(SdkHttpError);
15911591
expect((error as SdkHttpError).code).toBe(SdkErrorCode.ClientHttpAuthentication);
15921592
expect((error as SdkHttpError).status).toBe(401);
1593+
expect((error as SdkHttpError).statusText).toBe('Unauthorized');
15931594
expect(authProvider.onUnauthorized).toHaveBeenCalledTimes(1);
15941595
expect(postCount).toBe(2);
15951596
});

packages/client/test/client/streamableHttp.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1875,6 +1875,7 @@ describe('StreamableHTTPClientTransport', () => {
18751875
expect(error).toBeInstanceOf(SdkHttpError);
18761876
expect((error as SdkHttpError).code).toBe(SdkErrorCode.ClientHttpAuthentication);
18771877
expect((error as SdkHttpError).status).toBe(401);
1878+
expect((error as SdkHttpError).statusText).toBe('Unauthorized');
18781879
expect(mockAuthProvider.saveTokens).toHaveBeenCalledWith({
18791880
access_token: 'new-access-token',
18801881
token_type: 'Bearer',

packages/client/test/client/tokenProvider.test.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -95,13 +95,26 @@ describe('StreamableHTTPClientTransport with AuthProvider', () => {
9595
vi.spyOn(globalThis, 'fetch');
9696

9797
(globalThis.fetch as Mock)
98-
.mockResolvedValueOnce({ ok: false, status: 401, headers: new Headers(), text: async () => 'unauthorized' })
99-
.mockResolvedValueOnce({ ok: false, status: 401, headers: new Headers(), text: async () => 'unauthorized' });
98+
.mockResolvedValueOnce({
99+
ok: false,
100+
status: 401,
101+
statusText: 'Unauthorized',
102+
headers: new Headers(),
103+
text: async () => 'unauthorized'
104+
})
105+
.mockResolvedValueOnce({
106+
ok: false,
107+
status: 401,
108+
statusText: 'Unauthorized',
109+
headers: new Headers(),
110+
text: async () => 'unauthorized'
111+
});
100112

101113
const error = await transport.send(message).catch(e => e);
102114
expect(error).toBeInstanceOf(SdkHttpError);
103115
expect((error as SdkHttpError).code).toBe(SdkErrorCode.ClientHttpAuthentication);
104116
expect((error as SdkHttpError).status).toBe(401);
117+
expect((error as SdkHttpError).statusText).toBe('Unauthorized');
105118
expect(authProvider.onUnauthorized).toHaveBeenCalledTimes(1);
106119
});
107120

packages/codemod/src/migrations/v1-to-v2/transforms/importPaths.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ const REEXPORT_WARNINGS: Record<string, string> = {
1414
'Re-exported RequestHandlerExtra was renamed to ServerContext/ClientContext in v2. Update this re-export manually.',
1515
IsomorphicHeaders: 'Re-exported IsomorphicHeaders was removed in v2 (replaced by standard Headers API). Remove this re-export.',
1616
StreamableHTTPError:
17-
'Re-exported StreamableHTTPError was renamed to SdkError in v2 with different constructor. Update this re-export manually.'
17+
'Re-exported StreamableHTTPError was renamed to SdkHttpError in v2 with a different constructor. Update this re-export manually.'
1818
};
1919

2020
export const importPathsTransform: Transform = {

packages/codemod/src/migrations/v1-to-v2/transforms/removedApis.ts

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -148,13 +148,14 @@ function handleStreamableHTTPError(sourceFile: SourceFile, diagnostics: Diagnost
148148
warning(
149149
sourceFile.getFilePath(),
150150
node.getStartLineNumber(),
151-
'new StreamableHTTPError(statusCode, statusText, body?) → new SdkError(code, message, data?). ' +
152-
'Constructor arguments differ — manual review required. Map HTTP status to SdkErrorCode enum value.'
151+
'new StreamableHTTPError(statusCode, statusText, body?) → new SdkHttpError(code, message, data). ' +
152+
'Constructor arguments differ — manual review required. Map the HTTP status to a SdkErrorCode enum value ' +
153+
'and pass the HTTP status via the data argument, e.g. { status, statusText }.'
153154
)
154155
);
155156
}
156157

157-
renameAllReferences(sourceFile, localName, 'SdkError');
158+
renameAllReferences(sourceFile, localName, 'SdkHttpError');
158159
changesCount++;
159160

160161
foundImport.remove();
@@ -164,16 +165,18 @@ function handleStreamableHTTPError(sourceFile: SourceFile, diagnostics: Diagnost
164165

165166
const targetModule = resolveTargetModule(sourceFile, moduleSpec);
166167
const insertIndex = sourceFile.getImportDeclarations().length;
167-
const importsToAdd = hasConstructorCalls ? ['SdkError', 'SdkErrorCode'] : ['SdkError'];
168+
const importsToAdd = hasConstructorCalls ? ['SdkHttpError', 'SdkErrorCode'] : ['SdkHttpError'];
168169
addOrMergeImport(sourceFile, targetModule, importsToAdd, false, insertIndex);
169170
changesCount++;
170171

171172
diagnostics.push(
172173
warning(
173174
sourceFile.getFilePath(),
174175
line,
175-
'StreamableHTTPError replaced with SdkError. Constructor arguments differ — manual review required. ' +
176-
'HTTP status is now in error.data?.status.'
176+
'StreamableHTTPError replaced with SdkHttpError (a subclass of SdkError). ' +
177+
'HTTP status and status text are now available via error.status and error.statusText. ' +
178+
'Note: unexpected-content-type responses (HTTP 200 with the wrong content type) are thrown as the ' +
179+
'base SdkError, not SdkHttpError, so a catch-all check should use `instanceof SdkError`.'
177180
)
178181
);
179182

packages/codemod/test/integration.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -324,8 +324,8 @@ describe('integration', () => {
324324
expect(output).toContain('const h: Headers');
325325
expect(output).not.toContain('IsomorphicHeaders');
326326

327-
// StreamableHTTPError renamed to SdkError
328-
expect(output).toContain('instanceof SdkError');
327+
// StreamableHTTPError renamed to SdkHttpError
328+
expect(output).toContain('instanceof SdkHttpError');
329329
expect(output).not.toContain('StreamableHTTPError');
330330

331331
// schemaToJson removed (import gone)

packages/codemod/test/v1-to-v2/transforms/removedApis.test.ts

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -163,37 +163,37 @@ describe('removed-apis transform', () => {
163163
});
164164
});
165165

166-
describe('StreamableHTTPError → SdkError', () => {
167-
it('renames StreamableHTTPError to SdkError in references', () => {
166+
describe('StreamableHTTPError → SdkHttpError', () => {
167+
it('renames StreamableHTTPError to SdkHttpError in references', () => {
168168
const input = [
169169
`import { StreamableHTTPError } from '@modelcontextprotocol/client';`,
170170
`if (error instanceof StreamableHTTPError) { throw error; }`,
171171
''
172172
].join('\n');
173173
const { text } = applyTransform(input);
174-
expect(text).toContain('instanceof SdkError');
174+
expect(text).toContain('instanceof SdkHttpError');
175175
expect(text).not.toContain('StreamableHTTPError');
176176
});
177177

178-
it('adds SdkError import without SdkErrorCode when no constructor calls', () => {
178+
it('adds SdkHttpError import without SdkErrorCode when no constructor calls', () => {
179179
const input = [
180180
`import { StreamableHTTPError } from '@modelcontextprotocol/client';`,
181181
`if (error instanceof StreamableHTTPError) {}`,
182182
''
183183
].join('\n');
184184
const { text } = applyTransform(input);
185-
expect(text).toContain('SdkError');
185+
expect(text).toContain('SdkHttpError');
186186
expect(text).not.toContain('SdkErrorCode');
187187
});
188188

189-
it('adds SdkError and SdkErrorCode imports when constructor calls exist', () => {
189+
it('adds SdkHttpError and SdkErrorCode imports when constructor calls exist', () => {
190190
const input = [
191191
`import { StreamableHTTPError } from '@modelcontextprotocol/client';`,
192192
`throw new StreamableHTTPError(404, 'Not Found');`,
193193
''
194194
].join('\n');
195195
const { text } = applyTransform(input);
196-
expect(text).toContain('SdkError');
196+
expect(text).toContain('SdkHttpError');
197197
expect(text).toContain('SdkErrorCode');
198198
});
199199

@@ -208,14 +208,14 @@ describe('removed-apis transform', () => {
208208
expect(constructorWarning).toBeDefined();
209209
});
210210

211-
it('emits general migration warning', () => {
211+
it('emits general migration warning pointing at the typed status accessor', () => {
212212
const input = [
213213
`import { StreamableHTTPError } from '@modelcontextprotocol/client';`,
214214
`if (error instanceof StreamableHTTPError) {}`,
215215
''
216216
].join('\n');
217217
const { result } = applyTransform(input);
218-
const migrationWarning = result.diagnostics.find(d => d.message.includes('error.data?.status'));
218+
const migrationWarning = result.diagnostics.find(d => d.message.includes('error.status'));
219219
expect(migrationWarning).toBeDefined();
220220
});
221221

@@ -227,7 +227,7 @@ describe('removed-apis transform', () => {
227227
].join('\n');
228228
const { text } = applyTransform(input);
229229
expect(text).not.toContain('import { StreamableHTTPError }');
230-
expect(text).toMatch(/import.*SdkError/);
230+
expect(text).toMatch(/import.*SdkHttpError/);
231231
});
232232

233233
it('is idempotent', () => {
@@ -249,9 +249,9 @@ describe('removed-apis transform', () => {
249249
''
250250
].join('\n');
251251
const { text, result } = applyTransform(input);
252-
expect(text).toContain('instanceof SdkError');
252+
expect(text).toContain('instanceof SdkHttpError');
253253
expect(text).not.toMatch(/\bSHE\b/);
254-
expect(text).toMatch(/import.*SdkError/);
254+
expect(text).toMatch(/import.*SdkHttpError/);
255255
const constructorWarning = result.diagnostics.find(d => d.message.includes('Constructor arguments differ'));
256256
expect(constructorWarning).toBeDefined();
257257
});
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { describe, it, expect } from 'vitest';
2+
import { SdkError, SdkErrorCode, SdkHttpError } from '../../src/index.js';
3+
4+
describe('SdkHttpError', () => {
5+
it('exposes status and statusText via getters', () => {
6+
const error = new SdkHttpError(SdkErrorCode.ClientHttpAuthentication, 'Unauthorized', {
7+
status: 401,
8+
statusText: 'Unauthorized'
9+
});
10+
11+
expect(error.status).toBe(401);
12+
expect(error.statusText).toBe('Unauthorized');
13+
});
14+
15+
it('returns undefined for statusText when omitted', () => {
16+
const error = new SdkHttpError(SdkErrorCode.ClientHttpAuthentication, 'auth failed', {
17+
status: 401
18+
});
19+
20+
expect(error.status).toBe(401);
21+
expect(error.statusText).toBeUndefined();
22+
});
23+
24+
it('is an instance of SdkError', () => {
25+
const error = new SdkHttpError(SdkErrorCode.ClientHttpForbidden, 'Forbidden', {
26+
status: 403,
27+
statusText: 'Forbidden'
28+
});
29+
30+
expect(error).toBeInstanceOf(SdkError);
31+
expect(error).toBeInstanceOf(SdkHttpError);
32+
});
33+
34+
it('preserves code and message from SdkError', () => {
35+
const error = new SdkHttpError(SdkErrorCode.ClientHttpNotImplemented, 'Not Implemented', {
36+
status: 501,
37+
statusText: 'Not Implemented'
38+
});
39+
40+
expect(error.code).toBe(SdkErrorCode.ClientHttpNotImplemented);
41+
expect(error.message).toBe('Not Implemented');
42+
expect(error.name).toBe('SdkHttpError');
43+
});
44+
45+
it('exposes extra data fields alongside status', () => {
46+
const error = new SdkHttpError(SdkErrorCode.ClientHttpAuthentication, 'auth failed', {
47+
status: 401,
48+
statusText: 'Unauthorized',
49+
retryAfter: 30
50+
});
51+
52+
expect(error.data.retryAfter).toBe(30);
53+
expect(error.status).toBe(401);
54+
});
55+
});

0 commit comments

Comments
 (0)