|
19 | 19 | * probe wire shape (string id, `server/discover` first, never a real request). |
20 | 20 | */ |
21 | 21 | import type { JSONRPCMessage, Transport } from '@modelcontextprotocol/core-internal'; |
22 | | -import { LATEST_PROTOCOL_VERSION, PROTOCOL_VERSION_META_KEY } from '@modelcontextprotocol/core-internal'; |
| 22 | +import { LATEST_PROTOCOL_VERSION, PROTOCOL_VERSION_META_KEY, SdkErrorCode, SdkHttpError } from '@modelcontextprotocol/core-internal'; |
23 | 23 | import { describe, expect, it } from 'vitest'; |
24 | 24 |
|
25 | 25 | import { Client } from '../../src/client/client'; |
@@ -161,6 +161,29 @@ const CORPUS: CorpusRow[] = [ |
161 | 161 | outcome: { kind: 'result', result: { content: [{ type: 'text', text: `supportedVersions: ["${MODERN}"]` }] } }, |
162 | 162 | expected: 'legacy' |
163 | 163 | }, |
| 164 | + // --- Auth statuses are never era evidence (#2561): an auth-protected |
| 165 | + // server is not a legacy server, whatever the body says — typed failure, |
| 166 | + // never initialize (fallbackAvailable is true in every row here). |
| 167 | + { |
| 168 | + name: 'auth: HTTP 401 challenge (WWW-Authenticate rides the header; body carries the OAuth error JSON) → typed auth failure, never legacy', |
| 169 | + outcome: { kind: 'http-error', status: 401, body: '{"error":"invalid_token","error_description":"Missing bearer token"}' }, |
| 170 | + expected: 'error' |
| 171 | + }, |
| 172 | + { |
| 173 | + name: 'auth: bare HTTP 401 (no body) → typed auth failure, never legacy', |
| 174 | + outcome: { kind: 'http-error', status: 401 }, |
| 175 | + expected: 'error' |
| 176 | + }, |
| 177 | + { |
| 178 | + name: 'auth: bare HTTP 403 denial (no body) → typed auth failure, never legacy', |
| 179 | + outcome: { kind: 'http-error', status: 403 }, |
| 180 | + expected: 'error' |
| 181 | + }, |
| 182 | + { |
| 183 | + name: 'auth: a 401 whose body parses as a JSON-RPC error is still an auth failure — the auth layer wrote that body, not server/discover', |
| 184 | + outcome: { kind: 'http-error', status: 401, body: DEPLOYED_SESSION_REQUIRED_BODY }, |
| 185 | + expected: 'error' |
| 186 | + }, |
164 | 187 | // --- Q12 transport-aware timeout rows (stdio falls back, HTTP stays a typed error). |
165 | 188 | { |
166 | 189 | name: 'timeout on stdio → legacy fallback (the stdio backward-compatibility rule)', |
@@ -209,6 +232,25 @@ describe('T9/T11 merged probe fixture corpus (probe classifier)', () => { |
209 | 232 | }); |
210 | 233 | } |
211 | 234 |
|
| 235 | + it('the 401/403 typed failures name the auth status and carry it with the response text and reason phrase', () => { |
| 236 | + for (const [status, statusText, body] of [ |
| 237 | + [401, 'Unauthorized', '{"error":"invalid_token"}'], |
| 238 | + [403, 'Forbidden', 'nope'] |
| 239 | + ] as const) { |
| 240 | + const verdict = classifyProbeOutcome({ kind: 'http-error', status, statusText, body }, baseContext); |
| 241 | + expect(verdict.kind).toBe('error'); |
| 242 | + if (verdict.kind === 'error') { |
| 243 | + expect(verdict.error).toBeInstanceOf(SdkHttpError); |
| 244 | + const error = verdict.error as SdkHttpError; |
| 245 | + expect(error.code).toBe(SdkErrorCode.EraNegotiationFailed); |
| 246 | + expect(error.status).toBe(status); |
| 247 | + expect(error.statusText).toBe(statusText); |
| 248 | + expect(error.data.text).toBe(body); |
| 249 | + expect(error.message).toContain(String(status)); |
| 250 | + } |
| 251 | + } |
| 252 | + }); |
| 253 | + |
212 | 254 | it('a DiscoverResult with a mutual version is the only result shape that yields a modern verdict', () => { |
213 | 255 | const verdict = classifyProbeOutcome( |
214 | 256 | { |
|
0 commit comments