Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 62 additions & 3 deletions test/client/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
extractWWWAuthenticateParams,
auth,
type OAuthClientProvider,
parseErrorResponse,
selectClientAuthMethod,
isHttpsUrl
} from '../../src/client/auth.js';
Expand Down Expand Up @@ -207,6 +208,20 @@ describe('OAuth Authorization', () => {
await expect(discoverOAuthProtectedResourceMetadata('https://resource.example.com')).rejects.toThrow('HTTP 500');
});

it('includes the discovery-failure substrings consumers classify on', async () => {
mockFetch.mockResolvedValueOnce({
ok: false,
status: 500
});

// Auth-failure classifiers recognize discovery failures by the substrings
// 'trying to load' and 'metadata', so both are pinned message ABI.
const error = await discoverOAuthProtectedResourceMetadata('https://resource.example.com').catch((e: unknown) => e);
expect(error).toBeInstanceOf(Error);
expect((error as Error).message).toContain('trying to load');
expect((error as Error).message).toContain('metadata');
});

it('validates metadata schema', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
Expand Down Expand Up @@ -687,6 +702,17 @@ describe('OAuth Authorization', () => {
await expect(discoverOAuthMetadata('https://auth.example.com')).rejects.toThrow('HTTP 500');
});

it('includes the discovery-failure substrings consumers classify on', async () => {
mockFetch.mockResolvedValueOnce(new Response(null, { status: 500 }));

// Auth-failure classifiers recognize discovery failures by the substrings
// 'trying to load' and 'metadata', so both are pinned message ABI.
const error = await discoverOAuthMetadata('https://auth.example.com').catch((e: unknown) => e);
expect(error).toBeInstanceOf(Error);
expect((error as Error).message).toContain('trying to load');
expect((error as Error).message).toContain('metadata');
});

it('validates metadata schema', async () => {
mockFetch.mockResolvedValueOnce(
Response.json(
Expand Down Expand Up @@ -848,6 +874,20 @@ describe('OAuth Authorization', () => {
await expect(discoverAuthorizationServerMetadata('https://mcp.example.com')).rejects.toThrow('HTTP 500');
});

it('includes the discovery-failure substrings consumers classify on', async () => {
mockFetch.mockResolvedValueOnce({
ok: false,
status: 500
});

// Auth-failure classifiers recognize discovery failures by the substrings
// 'trying to load' and 'metadata', so both are pinned message ABI.
const error = await discoverAuthorizationServerMetadata('https://mcp.example.com').catch((e: unknown) => e);
expect(error).toBeInstanceOf(Error);
expect((error as Error).message).toContain('trying to load');
expect((error as Error).message).toContain('metadata');
});

it('handles CORS errors with retry', async () => {
// First call fails with CORS
mockFetch.mockImplementationOnce(() => Promise.reject(new TypeError('CORS error')));
Expand Down Expand Up @@ -1435,7 +1475,7 @@ describe('OAuth Authorization', () => {
clientInformation: validClientInfo,
redirectUrl: 'http://localhost:3000/callback'
})
).rejects.toThrow(/does not support response type/);
).rejects.toThrow(/^Incompatible auth server: does not support response type/);
});

// https://github.com/modelcontextprotocol/typescript-sdk/issues/832
Expand Down Expand Up @@ -1473,7 +1513,7 @@ describe('OAuth Authorization', () => {
clientInformation: validClientInfo,
redirectUrl: 'http://localhost:3000/callback'
})
).rejects.toThrow(/does not support code challenge method/);
).rejects.toThrow(/^Incompatible auth server: does not support code challenge method/);
}
);
});
Expand Down Expand Up @@ -1969,7 +2009,7 @@ describe('OAuth Authorization', () => {
metadata,
clientMetadata: validClientMetadata
})
).rejects.toThrow(/does not support dynamic client registration/);
).rejects.toThrow(/^Incompatible auth server: does not support dynamic client registration/);
});

it('throws on error response', async () => {
Expand All @@ -1985,6 +2025,25 @@ describe('OAuth Authorization', () => {
});
});

describe('parseErrorResponse', () => {
// When a 401/403 response body is not a valid OAuth error document, the fallback
// ServerError message starts with 'HTTP <status>: '. Consumers match /^HTTP 40[13]\b/
// on that message to classify auth failures, so the head format is pinned ABI.
it('prefixes the fallback message with the HTTP status for non-OAuth 401 bodies', async () => {
const error = await parseErrorResponse(new Response('Unauthorized', { status: 401 }));

expect(error).toBeInstanceOf(ServerError);
expect(error.message).toMatch(/^HTTP 401\b/);
});

it('prefixes the fallback message with the HTTP status for non-OAuth 403 bodies', async () => {
const error = await parseErrorResponse(new Response('Forbidden', { status: 403 }));

expect(error).toBeInstanceOf(ServerError);
expect(error.message).toMatch(/^HTTP 403\b/);
});
});

describe('auth function', () => {
const mockProvider: OAuthClientProvider = {
get redirectUrl() {
Expand Down
24 changes: 23 additions & 1 deletion test/client/streamableHttp.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import { StartSSEOptions, StreamableHTTPClientTransport, StreamableHTTPReconnectionOptions } from '../../src/client/streamableHttp.js';
import {
StartSSEOptions,
StreamableHTTPClientTransport,
StreamableHTTPError,
StreamableHTTPReconnectionOptions
} from '../../src/client/streamableHttp.js';
import { OAuthClientProvider, UnauthorizedError } from '../../src/client/auth.js';
import { JSONRPCMessage, JSONRPCRequest } from '../../src/types.js';
import { InvalidClientError, InvalidGrantError, UnauthorizedClientError } from '../../src/server/auth/errors.js';
Expand Down Expand Up @@ -274,6 +279,23 @@ describe('StreamableHTTPClientTransport', () => {
expect(global.fetch).toHaveBeenCalledTimes(2);
});

it('should reject a failed GET connection with the Failed to open SSE stream marker', async () => {
// A 404 on the GET stream must surface as a StreamableHTTPError whose message carries
// the 'Failed to open SSE stream' marker: consumers use exactly that pair to tell a
// missing SSE endpoint apart from an expired session (which is also a 404).
(global.fetch as Mock).mockResolvedValueOnce({
ok: false,
status: 404,
statusText: 'Not Found'
});

await transport.start();
const error = await transport['_startOrAuthSse']({}).catch((e: unknown) => e);
expect(error).toBeInstanceOf(StreamableHTTPError);
expect((error as StreamableHTTPError).code).toBe(404);
expect((error as StreamableHTTPError).message).toContain('Failed to open SSE stream');
});

it('should handle successful initial GET connection for SSE', async () => {
// Set up readable stream for SSE events
const encoder = new TextEncoder();
Expand Down
52 changes: 51 additions & 1 deletion test/e2e/requirements.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,8 @@ export const REQUIREMENTS: Record<string, Requirement> = {
},
'typescript:protocol:error:connection-closed': {
source: 'sdk',
behavior: 'Closing the transport invokes onclose and rejects all in-flight requests with ErrorCode.ConnectionClosed.',
behavior:
'Closing the transport invokes onclose and rejects all in-flight requests with ErrorCode.ConnectionClosed — literal code -32000 and a message containing "Connection closed". Both halves of that duck shape are consumer-matched ABI (consumers avoid instanceof across package boundaries), so the literals are pinned, not just the enum member.',
knownFailures: [
{
transport: 'stdio',
Expand Down Expand Up @@ -1465,6 +1466,36 @@ export const REQUIREMENTS: Record<string, Requirement> = {
'ClientOptions.jsonSchemaValidator swaps the JSON Schema validator implementation: the configured provider is the one consulted for client-side tool outputSchema validation and its verdicts are honored.'
},

// Strict/loose validation boundaries (SDK)

'typescript:types:empty-result-strict': {
source: 'sdk',
behavior:
'Empty-result acks (e.g. ping) are validated with a strict schema: a server result carrying an extra non-_meta field rejects client-side with the raw validator error instead of resolving.'
},
'typescript:types:envelope-strict': {
source: 'sdk',
behavior:
'The JSON-RPC envelope schemas are strict: an otherwise-valid request carrying an unknown top-level sibling field is rejected at the wire boundary — HTTP hosting answers 400 with a -32700 error body, and the stdio server transport reports onerror, drops the message, and keeps serving subsequent requests.',
transports: ['stdio', 'streamableHttp'],
note: 'The envelope schemas run where wire bytes are deserialized: the stdio read buffer and the HTTP POST body parser (exercised against the stateless host under the streamableHttp-labelled cell; the legacy SSE POST path runs the same schema parse). inMemory delivers structured objects with no deserialization, so the boundary does not exist there.'
},
'typescript:types:request-params-strip': {
source: 'sdk',
behavior:
'Typed request params schemas operate in strip mode: an unknown sibling field sent next to declared params (e.g. on tools/call) is accepted — not rejected — and is absent from the params the server-side handler receives.'
},
'typescript:types:result-passthrough': {
source: 'sdk',
behavior:
'Typed result schemas are loose per-type: unknown top-level sibling fields a server attaches to a tools/call result survive the client-side result parse and are surfaced to the consumer alongside the declared fields.'
},
'typescript:types:completion-result-loose': {
source: 'sdk',
behavior:
'The completion object inside a completion/complete result is loose: a raw handler can attach unknown sibling fields next to values and they reach the requesting client intact.'
},

// Hosting: session lifecycle

'hosting:session:cors-expose': {
Expand Down Expand Up @@ -1526,6 +1557,13 @@ export const REQUIREMENTS: Record<string, Requirement> = {
}
]
},
'typescript:consumer:session-expiry-message': {
source: 'sdk',
behavior:
"When the documented per-session hosting pattern rejects an unrecognized session id (HTTP 400, JSON-RPC error body whose message contains 'No valid session ID'), the client surfaces the HTTP status as StreamableHTTPError.code and embeds the raw body text in the error message. Consumers detect session expiry by regex-matching that message and reading .code, so both are pinned ABI.",
transports: ['streamableHttp'],
note: 'This exercises the HTTP hosting layer and session management; the matrix transport arg is ignored, so it runs as a single streamableHttp-labelled cell to avoid duplicate runs.'
},
'hosting:stateless:concurrent-clients': {
source: 'sdk',
behavior: 'Multiple independent clients can connect to a stateless server concurrently.',
Expand Down Expand Up @@ -2341,6 +2379,18 @@ export const REQUIREMENTS: Record<string, Requirement> = {
note: 'This is a multi-hop proxy flow that should work across transports; restricted to inMemory and streamableHttp to avoid test matrix bloat.'
},
// Consumer-contract additions (sourced from real SDK dependents)
'typescript:consumer:result-validation-error': {
source: 'sdk',
behavior:
'When a response result fails the consumer-supplied result schema, client.request rejects with the raw validator error — an issues-bearing error that is not an McpError and has no JSON-RPC error code — so consumers can distinguish local validation failures from remote protocol errors.'
},
'typescript:consumer:connect-validation-error': {
source: 'sdk',
behavior:
'When the server answers initialize with a schema-invalid body, Client.connect rejects with the raw validator error (issues-bearing, not an McpError) and detaches the transport; consumers branch on this error class to route auth and failure handling.',
transports: ['inMemory'],
note: 'The test supplies its own custom Transport implementation answering initialize with an invalid body, so the matrix transport arg is ignored; it runs as a single inMemory-labelled cell to avoid duplicate runs.'
},
'client-transport:http:error-status-code': {
source: 'sdk',
behavior:
Expand Down
Loading
Loading