Add consumer-sourced e2e requirements and the legacy SSE matrix column (#2203)
This commit is contained in:
parent
1998a186ee
commit
4a5c863a21
8 changed files with 1431 additions and 11 deletions
|
|
@ -11,6 +11,7 @@ import { createHash, generateKeyPairSync, sign } from 'node:crypto';
|
|||
import type { AuthProvider, OAuthClientProvider } from '@modelcontextprotocol/client';
|
||||
import {
|
||||
applyMiddlewares,
|
||||
auth,
|
||||
Client,
|
||||
ClientCredentialsProvider,
|
||||
createMiddleware,
|
||||
|
|
@ -20,7 +21,10 @@ import {
|
|||
OAuthError,
|
||||
OAuthErrorCode,
|
||||
PrivateKeyJwtProvider,
|
||||
refreshAuthorization,
|
||||
SdkError,
|
||||
SSEClientTransport,
|
||||
SseError,
|
||||
startAuthorization,
|
||||
StaticPrivateKeyJwtProvider,
|
||||
StreamableHTTPClientTransport,
|
||||
|
|
@ -1807,3 +1811,230 @@ verifies('client-auth:authprovider:oauth-provider-adapted', async (_args: TestAr
|
|||
await mcpHost.close();
|
||||
}
|
||||
});
|
||||
|
||||
verifies('client-auth:auth-helper:result-values', async (_args: TestArgs) => {
|
||||
const ISSUED = 'auth-helper-access-token';
|
||||
const as = createMockAuthorizationServer({
|
||||
tokenResponses: [{ access_token: ISSUED, token_type: 'Bearer', refresh_token: 'auth-helper-refresh-token' }]
|
||||
});
|
||||
const provider = new RecordingOAuthClientProvider({ clientInformation: { client_id: 'auth-helper-client' } });
|
||||
const fetchFn = (url: URL | string, init?: RequestInit) => as.handleRequest(new Request(url, init));
|
||||
|
||||
// No tokens and no authorization code: the helper starts the redirect flow and reports it with the literal string.
|
||||
const redirectResult = await auth(provider, { serverUrl: MCP_URL, fetchFn });
|
||||
expect(redirectResult).toBe('REDIRECT');
|
||||
expect(provider.redirectedTo).toHaveLength(1);
|
||||
const redirect = defined(provider.redirectedTo[0], 'authorization redirect URL');
|
||||
expect(redirect.origin + redirect.pathname).toBe(`${ISSUER}/authorize`);
|
||||
expect(provider.saved.codeVerifier).toBeDefined();
|
||||
expect(as.tokenCalls).toHaveLength(0);
|
||||
|
||||
// Completing the code exchange: tokens are persisted and the helper reports success with the literal string.
|
||||
const authorizedResult = await auth(provider, { serverUrl: MCP_URL, authorizationCode: 'granted-authorization-code', fetchFn });
|
||||
expect(authorizedResult).toBe('AUTHORIZED');
|
||||
expect(as.tokenCalls).toHaveLength(1);
|
||||
const tokenCall = defined(as.tokenCalls[0], 'token call');
|
||||
expect(tokenCall.body.get('grant_type')).toBe('authorization_code');
|
||||
expect(tokenCall.body.get('code')).toBe('granted-authorization-code');
|
||||
expect(provider.saved.tokens?.access_token).toBe(ISSUED);
|
||||
});
|
||||
|
||||
verifies('client-auth:refresh:typed-errors', async (_args: TestArgs) => {
|
||||
// Token endpoint that always rejects with the given RFC 6749 error body.
|
||||
const oauthErrorFetch =
|
||||
(errorCode: string) =>
|
||||
async (_url: URL | string, _init?: RequestInit): Promise<Response> =>
|
||||
Response.json({ error: errorCode, error_description: `mock ${errorCode} response` }, { status: 400 });
|
||||
|
||||
// The per-code classes are not exported by v2, so the typed-class contract is asserted via the rejection's constructor name.
|
||||
const expectTypedRejection = async (attempt: Promise<unknown>, expectedClassName: string, expectedCode: string) => {
|
||||
const rejection: unknown = await attempt.then(
|
||||
() => {
|
||||
throw new Error('token request unexpectedly resolved');
|
||||
},
|
||||
(error: unknown) => error
|
||||
);
|
||||
expect(rejection).toBeInstanceOf(OAuthError);
|
||||
if (!(rejection instanceof OAuthError)) throw new Error('expected an OAuthError rejection');
|
||||
expect(rejection.name).toBe(expectedClassName);
|
||||
expect(rejection.code).toBe(expectedCode);
|
||||
expect(rejection.message).toContain(`mock ${expectedCode} response`);
|
||||
};
|
||||
|
||||
const clientInformation = { client_id: 'typed-error-client' };
|
||||
|
||||
const refreshCases: Array<{ error: string; expectedClassName: string }> = [
|
||||
{ error: 'invalid_grant', expectedClassName: 'InvalidGrantError' },
|
||||
{ error: 'invalid_client', expectedClassName: 'InvalidClientError' },
|
||||
{ error: 'server_error', expectedClassName: 'ServerError' },
|
||||
{ error: 'temporarily_unavailable', expectedClassName: 'TemporarilyUnavailableError' }
|
||||
];
|
||||
for (const { error, expectedClassName } of refreshCases) {
|
||||
await expectTypedRejection(
|
||||
refreshAuthorization(ISSUER, {
|
||||
clientInformation,
|
||||
refreshToken: 'long-lived-refresh-token',
|
||||
fetchFn: oauthErrorFetch(error)
|
||||
}),
|
||||
expectedClassName,
|
||||
error
|
||||
);
|
||||
}
|
||||
|
||||
const exchangeCases: Array<{ error: string; expectedClassName: string }> = [
|
||||
{ error: 'invalid_grant', expectedClassName: 'InvalidGrantError' },
|
||||
{ error: 'invalid_client', expectedClassName: 'InvalidClientError' }
|
||||
];
|
||||
for (const { error, expectedClassName } of exchangeCases) {
|
||||
await expectTypedRejection(
|
||||
exchangeAuthorization(ISSUER, {
|
||||
clientInformation,
|
||||
authorizationCode: 'granted-authorization-code',
|
||||
codeVerifier: 'a-code-verifier',
|
||||
redirectUri: 'http://localhost:3000/callback',
|
||||
fetchFn: oauthErrorFetch(error)
|
||||
}),
|
||||
expectedClassName,
|
||||
error
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
verifies('client-auth:no-tokens:no-auth-header', async (_args: TestArgs) => {
|
||||
// Phase 1: tokens() returns undefined — the request goes out with no Authorization header and the 401 re-enters the auth flow.
|
||||
const noTokensAs = createMockAuthorizationServer();
|
||||
const noTokensProvider = new RecordingOAuthClientProvider({ clientInformation: { client_id: 'no-tokens-client' } });
|
||||
const noTokensHost = createAuthenticatedHost('token-never-issued');
|
||||
const noTokensBaseFetch = createCombinedFetch({ as: noTokensAs, mcpHost: noTokensHost, validToken: 'token-never-issued' });
|
||||
|
||||
const noTokensMcpHeaders: Array<Record<string, string>> = [];
|
||||
const noTokensFetch = async (url: URL | string, init?: RequestInit): Promise<Response> => {
|
||||
const urlObj = typeof url === 'string' ? new URL(url) : url;
|
||||
if (urlObj.origin !== ISSUER && !urlObj.pathname.includes('/.well-known/') && init?.method === 'POST') {
|
||||
const headers: Record<string, string> = {};
|
||||
for (const [k, v] of new Headers(init?.headers).entries()) {
|
||||
headers[k] = v;
|
||||
}
|
||||
noTokensMcpHeaders.push(headers);
|
||||
}
|
||||
return noTokensBaseFetch(url, init);
|
||||
};
|
||||
|
||||
const noTokensClient = new Client({ name: 'c', version: '0' });
|
||||
const noTokensTransport = new StreamableHTTPClientTransport(new URL(MCP_URL), { authProvider: noTokensProvider, fetch: noTokensFetch });
|
||||
|
||||
try {
|
||||
await expect(noTokensClient.connect(noTokensTransport)).rejects.toThrow(UnauthorizedError);
|
||||
|
||||
// The single unauthenticated POST carried no Authorization header at all.
|
||||
expect(noTokensMcpHeaders).toHaveLength(1);
|
||||
expect(defined(noTokensMcpHeaders[0], 'unauthenticated POST headers')['authorization']).toBeUndefined();
|
||||
|
||||
// The resulting 401 re-entered the auth flow: the user is redirected to the authorization endpoint.
|
||||
expect(noTokensProvider.redirectedTo).toHaveLength(1);
|
||||
const noTokensRedirect = defined(noTokensProvider.redirectedTo[0], 'authorization redirect URL');
|
||||
expect(noTokensRedirect.origin + noTokensRedirect.pathname).toBe(`${ISSUER}/authorize`);
|
||||
expect(noTokensProvider.saved.codeVerifier).toBeDefined();
|
||||
} finally {
|
||||
await noTokensClient.close();
|
||||
await noTokensHost.close();
|
||||
}
|
||||
|
||||
// Phase 2: stored tokens lack refresh_token — expiry leads back to the authorization-code flow, never a refresh attempt.
|
||||
const noRefreshAs = createMockAuthorizationServer();
|
||||
const noRefreshProvider = new RecordingOAuthClientProvider({
|
||||
tokens: { access_token: 'expired-access-token', token_type: 'Bearer' },
|
||||
clientInformation: { client_id: 'no-refresh-client' }
|
||||
});
|
||||
const noRefreshHost = createAuthenticatedHost('token-never-issued');
|
||||
const noRefreshBaseFetch = createCombinedFetch({ as: noRefreshAs, mcpHost: noRefreshHost, validToken: 'token-never-issued' });
|
||||
|
||||
const noRefreshMcpAuthHeaders: Array<string | null> = [];
|
||||
const noRefreshFetch = async (url: URL | string, init?: RequestInit): Promise<Response> => {
|
||||
const urlObj = typeof url === 'string' ? new URL(url) : url;
|
||||
if (urlObj.origin !== ISSUER && !urlObj.pathname.includes('/.well-known/') && init?.method === 'POST') {
|
||||
noRefreshMcpAuthHeaders.push(new Headers(init?.headers).get('authorization'));
|
||||
}
|
||||
return noRefreshBaseFetch(url, init);
|
||||
};
|
||||
|
||||
const noRefreshClient = new Client({ name: 'c', version: '0' });
|
||||
const noRefreshTransport = new StreamableHTTPClientTransport(new URL(MCP_URL), {
|
||||
authProvider: noRefreshProvider,
|
||||
fetch: noRefreshFetch
|
||||
});
|
||||
|
||||
try {
|
||||
await expect(noRefreshClient.connect(noRefreshTransport)).rejects.toThrow(UnauthorizedError);
|
||||
|
||||
// The expired bearer was sent once, but with no refresh_token there is no token-endpoint call at all (no refresh grant).
|
||||
expect(noRefreshMcpAuthHeaders).toEqual(['Bearer expired-access-token']);
|
||||
expect(noRefreshAs.tokenCalls).toHaveLength(0);
|
||||
|
||||
// Instead the full authorization-code flow restarts.
|
||||
expect(noRefreshProvider.redirectedTo).toHaveLength(1);
|
||||
const noRefreshRedirect = defined(noRefreshProvider.redirectedTo[0], 'authorization redirect URL');
|
||||
expect(noRefreshRedirect.origin + noRefreshRedirect.pathname).toBe(`${ISSUER}/authorize`);
|
||||
} finally {
|
||||
await noRefreshClient.close();
|
||||
await noRefreshHost.close();
|
||||
}
|
||||
});
|
||||
|
||||
verifies('client-transport:sse:401-unauthorized-code', async (_args: TestArgs) => {
|
||||
const wwwAuthenticate = `Bearer resource_metadata="${MCP_URL}/.well-known/oauth-protected-resource"`;
|
||||
|
||||
// Phase 1: no authProvider — the 401 surfaces as an SseError carrying the HTTP status code.
|
||||
const bareFetch = async (_url: URL | string, _init?: RequestInit): Promise<Response> =>
|
||||
new Response(null, { status: 401, headers: { 'WWW-Authenticate': wwwAuthenticate } });
|
||||
|
||||
const bareTransport = new SSEClientTransport(new URL(MCP_URL), { fetch: bareFetch });
|
||||
try {
|
||||
const rejection: unknown = await bareTransport.start().then(
|
||||
() => {
|
||||
throw new Error('start() unexpectedly resolved');
|
||||
},
|
||||
(error: unknown) => error
|
||||
);
|
||||
expect(rejection).toBeInstanceOf(SseError);
|
||||
if (!(rejection instanceof SseError)) throw new Error('expected an SseError rejection');
|
||||
expect(rejection.code).toBe(401);
|
||||
} finally {
|
||||
await bareTransport.close();
|
||||
}
|
||||
|
||||
// Phase 2: with an authProvider the same 401 drives the auth flow (redirect + UnauthorizedError), and finishAuth completes the exchange.
|
||||
const as = createMockAuthorizationServer({
|
||||
tokenResponses: [{ access_token: 'sse-access-token', token_type: 'Bearer' }]
|
||||
});
|
||||
const provider = new RecordingOAuthClientProvider({ clientInformation: { client_id: 'sse-client' } });
|
||||
|
||||
const combinedFetch = async (url: URL | string, init?: RequestInit): Promise<Response> => {
|
||||
const urlObj = typeof url === 'string' ? new URL(url) : url;
|
||||
if (urlObj.origin === ISSUER || urlObj.pathname.includes('/.well-known/')) {
|
||||
return as.handleRequest(new Request(url, init));
|
||||
}
|
||||
return new Response(null, { status: 401, headers: { 'WWW-Authenticate': wwwAuthenticate } });
|
||||
};
|
||||
|
||||
const authTransport = new SSEClientTransport(new URL(MCP_URL), { authProvider: provider, fetch: combinedFetch });
|
||||
try {
|
||||
await expect(authTransport.start()).rejects.toBeInstanceOf(UnauthorizedError);
|
||||
|
||||
// Same retry semantics as the streamable HTTP transport: the 401 redirected the user to the authorization endpoint.
|
||||
expect(provider.redirectedTo).toHaveLength(1);
|
||||
const redirect = defined(provider.redirectedTo[0], 'authorization redirect URL');
|
||||
expect(redirect.origin + redirect.pathname).toBe(`${ISSUER}/authorize`);
|
||||
expect(provider.saved.codeVerifier).toBeDefined();
|
||||
|
||||
// finishAuth exchanges the callback code for tokens, mirroring the streamable HTTP transport surface.
|
||||
await authTransport.finishAuth('granted-authorization-code');
|
||||
expect(as.tokenCalls).toHaveLength(1);
|
||||
const tokenCall = defined(as.tokenCalls[0], 'token call');
|
||||
expect(tokenCall.body.get('grant_type')).toBe('authorization_code');
|
||||
expect(tokenCall.body.get('code')).toBe('granted-authorization-code');
|
||||
expect(provider.saved.tokens?.access_token).toBe('sse-access-token');
|
||||
} finally {
|
||||
await authTransport.close();
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -896,3 +896,60 @@ verifies('hosting:session:delete-cancels-inflight', async (_args: TestArgs) => {
|
|||
await host.close();
|
||||
}
|
||||
});
|
||||
|
||||
verifies('hosting:session:lifecycle-callbacks', async (_args: TestArgs) => {
|
||||
// Hosts use these two callbacks to maintain a session-id -> transport map, so the test routes through one.
|
||||
const initializedSessions: string[] = [];
|
||||
const closedSessions: string[] = [];
|
||||
const sessions = new Map<string, NodeStreamableHTTPServerTransport>();
|
||||
|
||||
const router = express.Router();
|
||||
router.all('/mcp', async (req, res) => {
|
||||
const sid = req.headers['mcp-session-id'];
|
||||
const existing = typeof sid === 'string' ? sessions.get(sid) : undefined;
|
||||
if (existing) {
|
||||
await existing.handleRequest(req, res, req.body);
|
||||
return;
|
||||
}
|
||||
const tx = new NodeStreamableHTTPServerTransport({
|
||||
sessionIdGenerator: randomUUID,
|
||||
onsessioninitialized: id => {
|
||||
initializedSessions.push(id);
|
||||
sessions.set(id, tx);
|
||||
},
|
||||
onsessionclosed: id => {
|
||||
closedSessions.push(id);
|
||||
sessions.delete(id);
|
||||
}
|
||||
});
|
||||
await echoServer().connect(tx);
|
||||
await tx.handleRequest(req, res, req.body);
|
||||
});
|
||||
|
||||
await using host = await startExpressMinimal(router);
|
||||
const client = newClient();
|
||||
const clientTx = new StreamableHTTPClientTransport(new URL('/mcp', host.baseUrl));
|
||||
|
||||
try {
|
||||
await client.connect(clientTx);
|
||||
|
||||
const sessionId = clientTx.sessionId;
|
||||
if (sessionId === undefined) throw new Error('initialize did not assign a session id');
|
||||
expect(initializedSessions).toEqual([sessionId]);
|
||||
expect(closedSessions).toEqual([]);
|
||||
expect([...sessions.keys()]).toEqual([sessionId]);
|
||||
|
||||
// The map populated by onsessioninitialized routes the follow-up call back to the same transport.
|
||||
const result = await client.callTool({ name: 'echo', arguments: { text: 'lifecycle' } });
|
||||
expect(result.content).toEqual([{ type: 'text', text: 'lifecycle' }]);
|
||||
expect(initializedSessions).toEqual([sessionId]);
|
||||
expect(closedSessions).toEqual([]);
|
||||
|
||||
await clientTx.terminateSession();
|
||||
expect(closedSessions).toEqual([sessionId]);
|
||||
expect(sessions.size).toBe(0);
|
||||
} finally {
|
||||
for (const tx of sessions.values()) await tx.close();
|
||||
await client.close();
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -13,12 +13,16 @@ import type {
|
|||
JSONRPCNotification,
|
||||
JSONRPCRequest,
|
||||
JSONRPCResponse,
|
||||
MessageExtraInfo,
|
||||
Notification,
|
||||
Progress,
|
||||
RequestId
|
||||
RequestId,
|
||||
Result,
|
||||
Transport
|
||||
} from '@modelcontextprotocol/server';
|
||||
import {
|
||||
InMemoryTransport,
|
||||
isJSONRPCResultResponse,
|
||||
McpServer,
|
||||
ProtocolError,
|
||||
ProtocolErrorCode,
|
||||
|
|
@ -1460,3 +1464,217 @@ verifies('protocol:result-validation:invalid-result-sdkerror', async ({ transpor
|
|||
await expect(call).rejects.toMatchObject({ code: SdkErrorCode.InvalidResult });
|
||||
await expect(call).rejects.toThrow(/Invalid result for x-e2e\/wrong-shape/);
|
||||
});
|
||||
|
||||
verifies('typescript:protocol:error:not-connected', async ({ transport }: TestArgs) => {
|
||||
const makeServer = () => {
|
||||
const s = new McpServer({ name: 's', version: '0' });
|
||||
s.registerTool('summarize_document', { inputSchema: z.object({ text: z.string() }) }, ({ text }) => ({
|
||||
content: [{ type: 'text', text: `summary: ${text}` }]
|
||||
}));
|
||||
return s;
|
||||
};
|
||||
|
||||
// Never-connected client: no transport yet, and request methods reject immediately.
|
||||
const client = newClient();
|
||||
expect(client.transport).toBeUndefined();
|
||||
const beforeConnect = client.listTools();
|
||||
await expect(beforeConnect).rejects.toBeInstanceOf(Error);
|
||||
await expect(beforeConnect).rejects.toMatchObject({ message: 'Not connected' });
|
||||
|
||||
// Once connected over the matrix transport, the same client serves requests normally.
|
||||
await using _ = await wire(transport, makeServer, client);
|
||||
expect(client.transport).toBeDefined();
|
||||
const listed = await client.listTools();
|
||||
expect(listed.tools.map(t => t.name)).toEqual(['summarize_document']);
|
||||
const result = await client.callTool({ name: 'summarize_document', arguments: { text: 'Q3 sales were flat.' } });
|
||||
expect(result.content).toEqual([{ type: 'text', text: 'summary: Q3 sales were flat.' }]);
|
||||
|
||||
// Closed-state checks use a direct InMemory pair: the in-process stdio harness never fires the client transport's onclose.
|
||||
const closedClient = newClient();
|
||||
const closedServer = makeServer();
|
||||
const [closedClientTx, closedServerTx] = InMemoryTransport.createLinkedPair();
|
||||
try {
|
||||
await closedServer.connect(closedServerTx);
|
||||
await closedClient.connect(closedClientTx);
|
||||
expect(closedClient.transport).toBe(closedClientTx);
|
||||
|
||||
await closedClient.close();
|
||||
|
||||
expect(closedClient.transport).toBeUndefined();
|
||||
const afterClose = closedClient.listTools();
|
||||
await expect(afterClose).rejects.toBeInstanceOf(Error);
|
||||
await expect(afterClose).rejects.toMatchObject({ message: 'Not connected' });
|
||||
} finally {
|
||||
await closedClient.close();
|
||||
await closedServer.close();
|
||||
}
|
||||
});
|
||||
|
||||
/** Consumer-implemented Transport: an in-process loopback that answers initialize and tools/list with canned results. */
|
||||
class LoopbackTransport implements Transport {
|
||||
onclose?: () => void;
|
||||
onerror?: (error: Error) => void;
|
||||
onmessage?: (message: JSONRPCMessage, extra?: MessageExtraInfo) => void;
|
||||
|
||||
readonly events: string[] = [];
|
||||
readonly clientRequests: JSONRPCRequest[] = [];
|
||||
callbacksPresentAtStart?: { onmessage: boolean; onclose: boolean; onerror: boolean };
|
||||
|
||||
constructor(private readonly serverProtocolVersion: string) {}
|
||||
|
||||
async start(): Promise<void> {
|
||||
this.callbacksPresentAtStart = {
|
||||
onmessage: this.onmessage !== undefined,
|
||||
onclose: this.onclose !== undefined,
|
||||
onerror: this.onerror !== undefined
|
||||
};
|
||||
this.events.push('start');
|
||||
}
|
||||
|
||||
async send(message: JSONRPCMessage): Promise<void> {
|
||||
this.events.push('method' in message ? `send:${message.method}` : 'send:response');
|
||||
if (!isRequest(message)) return;
|
||||
this.clientRequests.push(message);
|
||||
if (message.method === 'initialize') {
|
||||
this.respond(message.id, {
|
||||
protocolVersion: this.serverProtocolVersion,
|
||||
capabilities: { tools: {} },
|
||||
serverInfo: { name: 'loopback-server', version: '3.1.4' }
|
||||
});
|
||||
} else if (message.method === 'tools/list') {
|
||||
this.respond(message.id, {
|
||||
tools: [{ name: 'lookup_order', description: 'Look up an order by id', inputSchema: { type: 'object' } }]
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
this.events.push('close');
|
||||
this.onclose?.();
|
||||
}
|
||||
|
||||
private respond(id: RequestId, result: Result): void {
|
||||
queueMicrotask(() => this.onmessage?.({ jsonrpc: '2.0', id, result }));
|
||||
}
|
||||
}
|
||||
|
||||
verifies('transport:custom:client-connect', async ({ protocolVersion }: TestArgs) => {
|
||||
// The body supplies its own consumer-implemented Transport, so the matrix transport arg is unused by design.
|
||||
const customTransport = new LoopbackTransport(protocolVersion);
|
||||
const client = newClient();
|
||||
const clientOnclose = vi.fn();
|
||||
client.onclose = clientOnclose;
|
||||
try {
|
||||
await client.connect(customTransport);
|
||||
|
||||
// Protocol installed its callbacks on the consumer object before invoking start().
|
||||
expect(customTransport.callbacksPresentAtStart).toEqual({ onmessage: true, onclose: true, onerror: true });
|
||||
// The full handshake ran over the consumer transport, and its canned identity is what the client now reports.
|
||||
expect(customTransport.events).toEqual(['start', 'send:initialize', 'send:notifications/initialized']);
|
||||
expect(client.getServerCapabilities()).toEqual({ tools: {} });
|
||||
expect(client.getServerVersion()).toEqual({ name: 'loopback-server', version: '3.1.4' });
|
||||
|
||||
// A post-handshake request round-trips through the consumer transport's send().
|
||||
const listed = await client.listTools();
|
||||
expect(listed.tools).toEqual([{ name: 'lookup_order', description: 'Look up an order by id', inputSchema: { type: 'object' } }]);
|
||||
expect(customTransport.clientRequests.map(m => m.method)).toEqual(['initialize', 'tools/list']);
|
||||
|
||||
await client.close();
|
||||
|
||||
// close() reached the consumer transport, and its onclose callback fed back into the client's close handling.
|
||||
expect(customTransport.events).toEqual(['start', 'send:initialize', 'send:notifications/initialized', 'send:tools/list', 'close']);
|
||||
expect(clientOnclose).toHaveBeenCalledTimes(1);
|
||||
expect(client.transport).toBeUndefined();
|
||||
} finally {
|
||||
await client.close();
|
||||
}
|
||||
});
|
||||
|
||||
verifies('protocol:transport-callbacks:wrappable-after-connect', async ({ transport }: TestArgs) => {
|
||||
const makeServer = () => {
|
||||
const s = new McpServer({ name: 's', version: '0' });
|
||||
s.registerTool('echo', { inputSchema: z.object({ text: z.string() }) }, ({ text }) => ({
|
||||
content: [{ type: 'text', text }]
|
||||
}));
|
||||
return s;
|
||||
};
|
||||
const client = newClient();
|
||||
await using _ = await wire(transport, makeServer, client);
|
||||
|
||||
const tx = client.transport;
|
||||
if (!tx) throw new Error('client transport not set after connect');
|
||||
|
||||
// Protocol assigned all three handlers at connect time, so a consumer has originals to chain to.
|
||||
const protocolOnMessage = tx.onmessage;
|
||||
const protocolOnClose = tx.onclose;
|
||||
const protocolOnError = tx.onerror;
|
||||
expect(protocolOnMessage).toBeDefined();
|
||||
expect(protocolOnClose).toBeDefined();
|
||||
expect(protocolOnError).toBeDefined();
|
||||
|
||||
// Consumer-style wrapping after connect: record, then delegate to the original handlers.
|
||||
const observedMessages: JSONRPCMessage[] = [];
|
||||
const observedErrors: Error[] = [];
|
||||
tx.onmessage = (message: JSONRPCMessage, extra?: MessageExtraInfo) => {
|
||||
observedMessages.push(message);
|
||||
protocolOnMessage?.(message, extra);
|
||||
};
|
||||
tx.onerror = (error: Error) => {
|
||||
observedErrors.push(error);
|
||||
protocolOnError?.(error);
|
||||
};
|
||||
tx.onclose = () => {
|
||||
protocolOnClose?.();
|
||||
};
|
||||
|
||||
const outbound = tapOutbound(client);
|
||||
|
||||
const first = await client.callTool({ name: 'echo', arguments: { text: 'wrapped dispatch' } });
|
||||
expect(first.content).toEqual([{ type: 'text', text: 'wrapped dispatch' }]);
|
||||
const second = await client.callTool({ name: 'echo', arguments: { text: 'still wrapped' } });
|
||||
expect(second.content).toEqual([{ type: 'text', text: 'still wrapped' }]);
|
||||
|
||||
// The wrapper observed the exact responses that resolved both calls, and no errors surfaced.
|
||||
const callIds = outbound
|
||||
.filter(m => isRequest(m))
|
||||
.filter(m => m.method === 'tools/call')
|
||||
.map(m => m.id);
|
||||
expect(callIds).toHaveLength(2);
|
||||
const [firstCallId, secondCallId] = callIds;
|
||||
if (firstCallId === undefined || secondCallId === undefined) throw new Error('tools/call request ids not captured');
|
||||
const observedResponsesFor = (id: RequestId) => observedMessages.filter(m => isJSONRPCResultResponse(m)).filter(m => m.id === id);
|
||||
expect(observedResponsesFor(firstCallId)).toHaveLength(1);
|
||||
expect(observedResponsesFor(firstCallId)[0]?.result).toMatchObject({ content: [{ type: 'text', text: 'wrapped dispatch' }] });
|
||||
expect(observedResponsesFor(secondCallId)).toHaveLength(1);
|
||||
expect(observedResponsesFor(secondCallId)[0]?.result).toMatchObject({ content: [{ type: 'text', text: 'still wrapped' }] });
|
||||
expect(observedErrors).toEqual([]);
|
||||
|
||||
// Close-event chaining is checked on a direct InMemory pair: the in-process stdio harness never fires the client transport's onclose.
|
||||
const closingClient = newClient();
|
||||
const closingServer = makeServer();
|
||||
const [closingClientTx, closingServerTx] = InMemoryTransport.createLinkedPair();
|
||||
const closingClientOnclose = vi.fn();
|
||||
closingClient.onclose = closingClientOnclose;
|
||||
try {
|
||||
await closingServer.connect(closingServerTx);
|
||||
await closingClient.connect(closingClientTx);
|
||||
|
||||
const protocolCloseHandler = closingClientTx.onclose;
|
||||
expect(protocolCloseHandler).toBeDefined();
|
||||
let wrapperObservedClose = 0;
|
||||
closingClientTx.onclose = () => {
|
||||
wrapperObservedClose += 1;
|
||||
protocolCloseHandler?.();
|
||||
};
|
||||
|
||||
// Server-initiated close: the consumer wrapper sees the close event and protocol close handling still runs through it.
|
||||
await closingServer.close();
|
||||
|
||||
expect(wrapperObservedClose).toBe(1);
|
||||
expect(closingClientOnclose).toHaveBeenCalledTimes(1);
|
||||
expect(closingClient.transport).toBeUndefined();
|
||||
} finally {
|
||||
await closingClient.close();
|
||||
await closingServer.close();
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -364,3 +364,58 @@ verifies('transport:stdio:default-env-safelist', async (_args: TestArgs) => {
|
|||
}
|
||||
}
|
||||
});
|
||||
|
||||
verifies('transport:stdio:pre-started-tolerated', async (_args: TestArgs) => {
|
||||
// Real consumers call transport.start() themselves (to capture early stderr) before handing the transport to connect().
|
||||
const transport = new StdioClientTransport({
|
||||
command: process.execPath,
|
||||
args: ['--import', 'tsx', FIXTURE_PATH],
|
||||
cwd: E2E_ROOT,
|
||||
stderr: 'pipe'
|
||||
});
|
||||
const stderr = transport.stderr;
|
||||
if (stderr === null) throw new Error('expected transport.stderr when spawned with stderr: "pipe"');
|
||||
let captured = '';
|
||||
stderr.on('data', (chunk: Buffer) => {
|
||||
captured += chunk.toString();
|
||||
});
|
||||
// Counts fixture boots observed on stderr — more than one means connect() double-spawned the server.
|
||||
const readyCount = () => captured.split('[stdio-server] ready').length - 1;
|
||||
|
||||
const client = newClient();
|
||||
try {
|
||||
await transport.start();
|
||||
const preStartedPid = transport.pid;
|
||||
if (preStartedPid === null) throw new Error('expected a child pid after manual start()');
|
||||
expect(processAlive(preStartedPid)).toBe(true);
|
||||
await vi.waitFor(() => expect(readyCount()).toBe(1), { timeout: 5000, interval: 25 });
|
||||
|
||||
// The contract is disjunctive: connect() over a pre-started transport either completes the handshake or rejects with the recognizable already-started error.
|
||||
let connectOutcome: { ok: true } | { ok: false; error: unknown } = { ok: true };
|
||||
try {
|
||||
await client.connect(transport);
|
||||
} catch (error) {
|
||||
connectOutcome = { ok: false, error };
|
||||
}
|
||||
|
||||
if (connectOutcome.ok) {
|
||||
// Tolerated branch: the handshake ran over the manually started child, so tool calls work...
|
||||
const echoed = await client.callTool({ name: 'echo', arguments: { text: 'pre-started transport' } });
|
||||
expect(echoed.isError).toBeFalsy();
|
||||
expect(echoed.content).toEqual([{ type: 'text', text: 'pre-started transport' }]);
|
||||
// ...and connect() did not spawn a second server process behind the consumer's back.
|
||||
expect(transport.pid).toBe(preStartedPid);
|
||||
expect(readyCount()).toBe(1);
|
||||
} else {
|
||||
if (!(connectOutcome.error instanceof Error)) throw new Error('expected connect() to reject with an Error');
|
||||
// Recognizable: the message names the already-started condition consumers branch on.
|
||||
expect(connectOutcome.error.message).toContain('StdioClientTransport already started');
|
||||
// Ignorable: the rejection left the manually started child alive, still owned by the transport, and not double-spawned.
|
||||
expect(processAlive(preStartedPid)).toBe(true);
|
||||
expect(transport.pid).toBe(preStartedPid);
|
||||
expect(readyCount()).toBe(1);
|
||||
}
|
||||
} finally {
|
||||
await transport.close();
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1247,3 +1247,42 @@ verifies('typescript:mcpserver:tool:extra', async ({ transport }: TestArgs) => {
|
|||
if (firstCallSeen === undefined || secondCallSeen === undefined) throw new Error('expected both calls to be recorded');
|
||||
expect(secondCallSeen.requestId).not.toBe(firstCallSeen.requestId);
|
||||
});
|
||||
|
||||
verifies('mcpserver:tool:metadata-roundtrip', async ({ transport }: TestArgs) => {
|
||||
// registerTool's public config carries title, description, annotations and _meta (no icons field), so those are asserted verbatim.
|
||||
const annotations = {
|
||||
title: 'Annotated Echo',
|
||||
readOnlyHint: true,
|
||||
destructiveHint: false,
|
||||
idempotentHint: true,
|
||||
openWorldHint: false
|
||||
};
|
||||
const meta = { 'example.com/source': 'metadata-roundtrip-fixture', 'example.com/revision': 3 };
|
||||
const makeServer = () => {
|
||||
const s = new McpServer({ name: 's', version: '0' });
|
||||
s.registerTool(
|
||||
'annotated-echo',
|
||||
{
|
||||
title: 'Annotated Echo',
|
||||
description: 'Echo tool carrying every metadata field registerTool accepts.',
|
||||
inputSchema: z.object({ text: z.string() }),
|
||||
annotations,
|
||||
_meta: meta
|
||||
},
|
||||
({ text }) => ({ content: [{ type: 'text', text }] })
|
||||
);
|
||||
return s;
|
||||
};
|
||||
const client = newClient();
|
||||
await using _ = await wire(transport, makeServer, client);
|
||||
|
||||
const { tools } = await client.listTools();
|
||||
expect(tools).toHaveLength(1);
|
||||
const tool = tools[0];
|
||||
if (tool === undefined) throw new Error('expected the registered tool to be listed');
|
||||
expect(tool.name).toBe('annotated-echo');
|
||||
expect(tool.title).toBe('Annotated Echo');
|
||||
expect(tool.description).toBe('Echo tool carrying every metadata field registerTool accepts.');
|
||||
expect(tool.annotations).toEqual(annotations);
|
||||
expect(tool._meta).toEqual(meta);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@
|
|||
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client';
|
||||
import { Client, SdkHttpError, StreamableHTTPClientTransport } from '@modelcontextprotocol/client';
|
||||
import { JSONRPCRequestSchema } from '@modelcontextprotocol/core';
|
||||
import {
|
||||
LATEST_PROTOCOL_VERSION,
|
||||
|
|
@ -30,7 +30,7 @@ import { expect, vi } from 'vitest';
|
|||
import { z } from 'zod/v4';
|
||||
|
||||
import type { HttpHandler } from '../helpers/index.js';
|
||||
import { hostPerSession } from '../helpers/index.js';
|
||||
import { hostPerSession, hostStateless } from '../helpers/index.js';
|
||||
import { verifies } from '../helpers/verifies.js';
|
||||
import type { TestArgs } from '../types.js';
|
||||
|
||||
|
|
@ -1270,3 +1270,235 @@ verifies('client-transport:http:reconnection-scheduler', async (_args: TestArgs)
|
|||
await handle.close();
|
||||
}
|
||||
});
|
||||
|
||||
verifies('client-transport:http:error-status-code', async (_args: TestArgs) => {
|
||||
const handle = hostPerSession(() => echoServer());
|
||||
|
||||
const expectHttpStatusError = (err: unknown, status: number): void => {
|
||||
expect(err).toBeInstanceOf(SdkHttpError);
|
||||
expect(err instanceof SdkHttpError ? err.status : undefined).toBe(status);
|
||||
};
|
||||
|
||||
try {
|
||||
const url = new URL('http://in-process/mcp');
|
||||
|
||||
// 404 on the connect POST: connect rejects with an SdkHttpError carrying the HTTP status on .status
|
||||
{
|
||||
const transport = new StreamableHTTPClientTransport(url, {
|
||||
fetch: async () => new Response('Not Found', { status: 404 })
|
||||
});
|
||||
const client = newClient();
|
||||
|
||||
const err = await client.connect(transport).then(
|
||||
() => {},
|
||||
(error: unknown) => error
|
||||
);
|
||||
expectHttpStatusError(err, 404);
|
||||
|
||||
await transport.close();
|
||||
}
|
||||
|
||||
// 401 with no authProvider configured: rejects with .status 401 so callers can branch on auth failures
|
||||
{
|
||||
const transport = new StreamableHTTPClientTransport(url, {
|
||||
fetch: async () => new Response('Unauthorized', { status: 401 })
|
||||
});
|
||||
const client = newClient();
|
||||
|
||||
const err = await client.connect(transport).then(
|
||||
() => {},
|
||||
(error: unknown) => error
|
||||
);
|
||||
expectHttpStatusError(err, 401);
|
||||
|
||||
await transport.close();
|
||||
}
|
||||
|
||||
// 500 on a POST after a successful connect: the in-flight request rejects with .status 500
|
||||
{
|
||||
let failToolCalls = false;
|
||||
const transport = new StreamableHTTPClientTransport(url, {
|
||||
fetch: async (u, init) => {
|
||||
const body = init?.body ? String(init.body) : undefined;
|
||||
if (failToolCalls && body?.includes('"tools/call"')) {
|
||||
return new Response('Internal Server Error', { status: 500 });
|
||||
}
|
||||
return handle.handleRequest(new Request(u, init));
|
||||
}
|
||||
});
|
||||
const client = newClient();
|
||||
|
||||
await client.connect(transport);
|
||||
failToolCalls = true;
|
||||
|
||||
const err = await client.callTool({ name: 'echo', arguments: { text: 'boom' } }).then(
|
||||
() => {},
|
||||
(error: unknown) => error
|
||||
);
|
||||
expectHttpStatusError(err, 500);
|
||||
|
||||
await client.close();
|
||||
await transport.close();
|
||||
}
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
});
|
||||
|
||||
verifies('typescript:client-transport:http:session-id-property', async (_args: TestArgs) => {
|
||||
const serverAssignedIds: Array<string | null> = [];
|
||||
const handle = hostPerSession(() => echoServer());
|
||||
const statelessHandle = hostStateless(() => echoServer());
|
||||
|
||||
try {
|
||||
const url = new URL('http://in-process/mcp');
|
||||
const transport = new StreamableHTTPClientTransport(url, {
|
||||
fetch: async (u, init) => {
|
||||
const response = await handle.handleRequest(new Request(u, init));
|
||||
if (init?.body && String(init.body).includes('"method":"initialize"')) {
|
||||
serverAssignedIds.push(response.headers.get('mcp-session-id'));
|
||||
}
|
||||
return response;
|
||||
}
|
||||
});
|
||||
const client = newClient();
|
||||
|
||||
expect(transport.sessionId).toBeUndefined();
|
||||
|
||||
await client.connect(transport);
|
||||
|
||||
expect(serverAssignedIds).toHaveLength(1);
|
||||
const assigned = serverAssignedIds[0];
|
||||
expect(assigned).not.toBeNull();
|
||||
expect(transport.sessionId).toBe(assigned);
|
||||
|
||||
await client.close();
|
||||
await transport.close();
|
||||
|
||||
// Stateless hosting assigns no Mcp-Session-Id, so the property stays undefined after connect
|
||||
const statelessTransport = new StreamableHTTPClientTransport(url, {
|
||||
fetch: (u, init) => statelessHandle.handleRequest(new Request(u, init))
|
||||
});
|
||||
const statelessClient = newClient();
|
||||
|
||||
expect(statelessTransport.sessionId).toBeUndefined();
|
||||
|
||||
await statelessClient.connect(statelessTransport);
|
||||
|
||||
expect(statelessTransport.sessionId).toBeUndefined();
|
||||
|
||||
await statelessClient.close();
|
||||
await statelessTransport.close();
|
||||
} finally {
|
||||
await handle.close();
|
||||
await statelessHandle.close();
|
||||
}
|
||||
});
|
||||
|
||||
verifies('typescript:client-transport:http:session-id-option', async (_args: TestArgs) => {
|
||||
const records: RecordedRequest[] = [];
|
||||
const handle = hostPerSession(() => echoServer());
|
||||
|
||||
try {
|
||||
const url = new URL('http://in-process/mcp');
|
||||
|
||||
// Establish a real session first so the configured sessionId refers to a live server session
|
||||
const firstTransport = new StreamableHTTPClientTransport(url, {
|
||||
fetch: (u, init) => handle.handleRequest(new Request(u, init))
|
||||
});
|
||||
const firstClient = newClient();
|
||||
await firstClient.connect(firstTransport);
|
||||
|
||||
const sessionId = firstTransport.sessionId;
|
||||
if (sessionId === undefined) throw new Error('expected the first connection to negotiate a session id');
|
||||
|
||||
const reusingTransport = new StreamableHTTPClientTransport(url, {
|
||||
sessionId,
|
||||
fetch: recordingFetch(records, handle.handleRequest)
|
||||
});
|
||||
const reusingClient = newClient();
|
||||
|
||||
await reusingClient.connect(reusingTransport);
|
||||
|
||||
const result = await reusingClient.callTool({ name: 'echo', arguments: { text: 'reused session' } });
|
||||
expect(result.content).toEqual([{ type: 'text', text: 'reused session' }]);
|
||||
|
||||
expect(reusingTransport.sessionId).toBe(sessionId);
|
||||
expect(records.length).toBeGreaterThanOrEqual(1);
|
||||
const firstRecorded = defined(records[0], 'first recorded request');
|
||||
expect(firstRecorded.method).toBe('POST');
|
||||
expect(firstRecorded.headers['mcp-session-id']).toBe(sessionId);
|
||||
for (const req of records) {
|
||||
expect(req.headers['mcp-session-id']).toBe(sessionId);
|
||||
}
|
||||
|
||||
await reusingClient.close();
|
||||
await reusingTransport.close();
|
||||
await firstClient.close();
|
||||
await firstTransport.close();
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
});
|
||||
|
||||
verifies('client-transport:http:reconnect-failure-onerror', async (_args: TestArgs) => {
|
||||
const records: RecordedRequest[] = [];
|
||||
const transportErrors: Error[] = [];
|
||||
let firstGetController: ReadableStreamDefaultController<Uint8Array> | undefined;
|
||||
let getCount = 0;
|
||||
const handle = hostPerSession(() => echoServer());
|
||||
|
||||
try {
|
||||
const url = new URL('http://in-process/mcp');
|
||||
const transport = new StreamableHTTPClientTransport(url, {
|
||||
fetch: recordingFetch(records, async req => {
|
||||
if (req.method === 'GET') {
|
||||
getCount++;
|
||||
if (getCount === 1) {
|
||||
return new Response(
|
||||
new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
firstGetController = controller;
|
||||
}
|
||||
}),
|
||||
{ status: 200, headers: { 'Content-Type': 'text/event-stream' } }
|
||||
);
|
||||
}
|
||||
// Every reconnection attempt fails so the retry budget is exhausted
|
||||
return new Response('Not Found', { status: 404 });
|
||||
}
|
||||
return handle.handleRequest(req);
|
||||
}),
|
||||
reconnectionOptions: {
|
||||
initialReconnectionDelay: 10,
|
||||
maxReconnectionDelay: 10,
|
||||
reconnectionDelayGrowFactor: 1,
|
||||
maxRetries: 2
|
||||
}
|
||||
});
|
||||
transport.onerror = error => void transportErrors.push(error);
|
||||
const client = newClient();
|
||||
|
||||
await client.connect(transport);
|
||||
|
||||
await vi.waitFor(() => expect(firstGetController).toBeDefined());
|
||||
defined(firstGetController, 'first GET stream controller').error(new Error('connection reset'));
|
||||
|
||||
// Each failed attempt and the final budget-exhausted failure are delivered to onerror
|
||||
await vi.waitFor(() =>
|
||||
expect(transportErrors.filter(e => e.message === 'Maximum reconnection attempts (2) exceeded.')).toHaveLength(1)
|
||||
);
|
||||
expect(transportErrors.filter(e => e.message.startsWith('Failed to reconnect SSE stream:'))).toHaveLength(2);
|
||||
expect(records.filter(r => r.method === 'GET')).toHaveLength(3);
|
||||
|
||||
// The reconnection failure stays on onerror: an unrelated request issued afterwards still succeeds
|
||||
const result = await client.callTool({ name: 'echo', arguments: { text: 'still works' } });
|
||||
expect(result.content).toEqual([{ type: 'text', text: 'still works' }]);
|
||||
expect(records.filter(r => r.method === 'GET')).toHaveLength(3);
|
||||
|
||||
await client.close();
|
||||
await transport.close();
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
});
|
||||
|
|
|
|||
239
test/e2e/scenarios/transport-raw.test.ts
Normal file
239
test/e2e/scenarios/transport-raw.test.ts
Normal file
|
|
@ -0,0 +1,239 @@
|
|||
/**
|
||||
* Raw transport relay tests: drive client and server transports directly via the
|
||||
* Transport interface (start/send/onmessage/onerror/onclose) without wrapping them
|
||||
* in a Client or Server — the pattern message-relay proxies depend on.
|
||||
*
|
||||
* One verifies() body dispatches on the matrix transport arg:
|
||||
* - inMemory: both ends of InMemoryTransport.createLinkedPair driven by hand
|
||||
* - stdio: StdioClientTransport spawning the stdio fixture server as a child process
|
||||
* - streamableHttp / streamableHttpStateless: StreamableHTTPClientTransport against
|
||||
* the in-process hostPerSession()/hostStateless() hosts
|
||||
*
|
||||
* Every JSON-RPC message is hand-built and every response is observed through the
|
||||
* transport's own onmessage callback, exactly as a relay would forward traffic.
|
||||
*/
|
||||
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/client';
|
||||
import { StdioClientTransport } from '@modelcontextprotocol/client/stdio';
|
||||
import { CallToolResultSchema, InitializeResultSchema, JSONRPCResultResponseSchema } from '@modelcontextprotocol/core';
|
||||
import type { JSONRPCMessage, JSONRPCNotification, JSONRPCRequest } from '@modelcontextprotocol/server';
|
||||
import { InMemoryTransport, McpServer } from '@modelcontextprotocol/server';
|
||||
import { expect, vi } from 'vitest';
|
||||
import { z } from 'zod/v4';
|
||||
|
||||
import { hostPerSession, hostStateless } from '../helpers/index.js';
|
||||
import { verifies } from '../helpers/verifies.js';
|
||||
import type { TestArgs } from '../types.js';
|
||||
|
||||
/** Absolute path to the runnable stdio fixture server (executed with tsx). */
|
||||
const FIXTURE_PATH = fileURLToPath(new URL('../fixtures/stdio-server.ts', import.meta.url));
|
||||
|
||||
/** E2E package root — spawn cwd so the workspace-local `tsx` resolves and tsconfig paths map workspace packages to source. */
|
||||
const E2E_ROOT = fileURLToPath(new URL('../', import.meta.url));
|
||||
|
||||
/** Hand-built initialize request a relay would forward verbatim (no Client involved). */
|
||||
function initializeRequest(id: number, protocolVersion: string): JSONRPCRequest {
|
||||
return {
|
||||
jsonrpc: '2.0',
|
||||
id,
|
||||
method: 'initialize',
|
||||
params: { protocolVersion, capabilities: {}, clientInfo: { name: 'raw-relay-client', version: '0' } }
|
||||
};
|
||||
}
|
||||
|
||||
const INITIALIZED_NOTIFICATION: JSONRPCNotification = { jsonrpc: '2.0', method: 'notifications/initialized' };
|
||||
|
||||
/** Hand-built tools/call request for the echo tool exposed by both real servers used below. */
|
||||
function echoCallRequest(id: number): JSONRPCRequest {
|
||||
return { jsonrpc: '2.0', id, method: 'tools/call', params: { name: 'echo', arguments: { text: 'relayed raw' } } };
|
||||
}
|
||||
|
||||
function echoServer(): McpServer {
|
||||
const s = new McpServer({ name: 'raw-relay-http-server', version: '0' });
|
||||
s.registerTool('echo', { description: 'Echo tool', inputSchema: z.object({ text: z.string() }) }, ({ text }) => ({
|
||||
content: [{ type: 'text', text }]
|
||||
}));
|
||||
return s;
|
||||
}
|
||||
|
||||
/** Narrows away `undefined` for values the surrounding test has already proven exist (replaces non-null assertions). */
|
||||
function defined<T>(value: T | undefined, label: string): T {
|
||||
if (value === undefined) throw new Error(`expected ${label} to be defined`);
|
||||
return value;
|
||||
}
|
||||
|
||||
/** Asserts the message observed via onmessage is the initialize response for `id` with the negotiated version. */
|
||||
function expectInitializeResponse(message: JSONRPCMessage, id: number, protocolVersion: string, serverName: string): void {
|
||||
const response = JSONRPCResultResponseSchema.parse(message);
|
||||
expect(response.id).toBe(id);
|
||||
const result = InitializeResultSchema.parse(response.result);
|
||||
expect(result.protocolVersion).toBe(protocolVersion);
|
||||
expect(result.serverInfo.name).toBe(serverName);
|
||||
}
|
||||
|
||||
/** Asserts the message observed via onmessage is the tools/call response for `id` carrying the echoed text. */
|
||||
function expectEchoResponse(message: JSONRPCMessage, id: number): void {
|
||||
const response = JSONRPCResultResponseSchema.parse(message);
|
||||
expect(response.id).toBe(id);
|
||||
const result = CallToolResultSchema.parse(response.result);
|
||||
expect(result.content).toEqual([{ type: 'text', text: 'relayed raw' }]);
|
||||
}
|
||||
|
||||
async function rawRelayInMemory(protocolVersion: string): Promise<void> {
|
||||
const [clientTx, serverTx] = InMemoryTransport.createLinkedPair();
|
||||
const clientReceived: JSONRPCMessage[] = [];
|
||||
const serverReceived: JSONRPCMessage[] = [];
|
||||
const errors: Error[] = [];
|
||||
let clientClosed = false;
|
||||
let serverClosed = false;
|
||||
|
||||
const initializeResult: JSONRPCMessage = {
|
||||
jsonrpc: '2.0',
|
||||
id: 1,
|
||||
result: { protocolVersion, capabilities: {}, serverInfo: { name: 'raw-relay-inmemory-server', version: '0' } }
|
||||
};
|
||||
|
||||
try {
|
||||
// The server-side transport is driven directly too: relay the hand-built result back without a Server.
|
||||
await serverTx.start();
|
||||
serverTx.onmessage = message => {
|
||||
serverReceived.push(message);
|
||||
void serverTx.send(initializeResult);
|
||||
};
|
||||
serverTx.onerror = error => void errors.push(error);
|
||||
serverTx.onclose = () => {
|
||||
serverClosed = true;
|
||||
};
|
||||
|
||||
await clientTx.start();
|
||||
clientTx.onmessage = message => void clientReceived.push(message);
|
||||
clientTx.onerror = error => void errors.push(error);
|
||||
clientTx.onclose = () => {
|
||||
clientClosed = true;
|
||||
};
|
||||
|
||||
const request = initializeRequest(1, protocolVersion);
|
||||
await clientTx.send(request);
|
||||
|
||||
// The raw request and hand-built response crossed the pair verbatim — nothing rewrote them.
|
||||
expect(serverReceived).toEqual([request]);
|
||||
await vi.waitFor(() => expect(clientReceived).toHaveLength(1));
|
||||
expect(clientReceived).toEqual([initializeResult]);
|
||||
expectInitializeResponse(defined(clientReceived[0], 'initialize response'), 1, protocolVersion, 'raw-relay-inmemory-server');
|
||||
|
||||
expect(errors).toEqual([]);
|
||||
expect(clientClosed).toBe(false);
|
||||
expect(serverClosed).toBe(false);
|
||||
|
||||
await clientTx.close();
|
||||
expect(clientClosed).toBe(true);
|
||||
expect(serverClosed).toBe(true);
|
||||
} finally {
|
||||
await clientTx.close();
|
||||
await serverTx.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function rawRelayStdio(protocolVersion: string): Promise<void> {
|
||||
// Direct spawn (node --import tsx) so the spawned process IS the fixture server, as in the other stdio scenarios.
|
||||
const transport = new StdioClientTransport({
|
||||
command: process.execPath,
|
||||
args: ['--import', 'tsx', FIXTURE_PATH],
|
||||
cwd: E2E_ROOT
|
||||
});
|
||||
const received: JSONRPCMessage[] = [];
|
||||
const errors: Error[] = [];
|
||||
let closed = false;
|
||||
|
||||
try {
|
||||
await transport.start();
|
||||
transport.onmessage = message => void received.push(message);
|
||||
transport.onerror = error => void errors.push(error);
|
||||
transport.onclose = () => {
|
||||
closed = true;
|
||||
};
|
||||
|
||||
await transport.send(initializeRequest(1, protocolVersion));
|
||||
// Generous first wait: tsx compiles the fixture inside the freshly spawned child before it can answer.
|
||||
await vi.waitFor(() => expect(received).toHaveLength(1), { timeout: 10_000, interval: 25 });
|
||||
expectInitializeResponse(defined(received[0], 'initialize response'), 1, protocolVersion, 'stdio-echo-server');
|
||||
|
||||
// Forward the rest of a relay's traffic by hand: initialized notification, then a tools/call.
|
||||
await transport.send(INITIALIZED_NOTIFICATION);
|
||||
await transport.send(echoCallRequest(2));
|
||||
await vi.waitFor(() => expect(received).toHaveLength(2), { timeout: 5000, interval: 25 });
|
||||
expectEchoResponse(defined(received[1], 'echo response'), 2);
|
||||
|
||||
expect(errors).toEqual([]);
|
||||
expect(closed).toBe(false);
|
||||
await transport.close();
|
||||
// close() ends the child's stdin; the resulting process exit must surface via onclose.
|
||||
await vi.waitFor(() => expect(closed).toBe(true), { timeout: 5000, interval: 25 });
|
||||
} finally {
|
||||
await transport.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function rawRelayStreamableHttp(protocolVersion: string, stateless: boolean): Promise<void> {
|
||||
const handle = stateless ? hostStateless(echoServer) : hostPerSession(echoServer);
|
||||
const records: Array<{ method: string }> = [];
|
||||
const received: JSONRPCMessage[] = [];
|
||||
const errors: Error[] = [];
|
||||
let closed = false;
|
||||
|
||||
const transport = new StreamableHTTPClientTransport(new URL('http://in-process/mcp'), {
|
||||
fetch: (url, init) => {
|
||||
const request = new Request(url, init);
|
||||
records.push({ method: request.method });
|
||||
return handle.handleRequest(request);
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
await transport.start();
|
||||
// start() must not touch the network — the first HTTP request may only happen on send().
|
||||
expect(records).toEqual([]);
|
||||
|
||||
transport.onmessage = message => void received.push(message);
|
||||
transport.onerror = error => void errors.push(error);
|
||||
transport.onclose = () => {
|
||||
closed = true;
|
||||
};
|
||||
|
||||
await transport.send(initializeRequest(1, protocolVersion));
|
||||
expect(records).toEqual([{ method: 'POST' }]);
|
||||
|
||||
await vi.waitFor(() => expect(received).toHaveLength(1), { timeout: 5000, interval: 10 });
|
||||
expectInitializeResponse(defined(received[0], 'initialize response'), 1, protocolVersion, 'raw-relay-http-server');
|
||||
|
||||
// Forward the rest of a relay's traffic by hand: initialized notification, then a tools/call.
|
||||
await transport.send(INITIALIZED_NOTIFICATION);
|
||||
await transport.send(echoCallRequest(2));
|
||||
await vi.waitFor(() => expect(received).toHaveLength(2), { timeout: 5000, interval: 10 });
|
||||
expectEchoResponse(defined(received[1], 'echo response'), 2);
|
||||
|
||||
// Every POST on the wire came from an explicit send(); the only other traffic is the optional standalone GET.
|
||||
expect(records.filter(r => r.method === 'POST')).toHaveLength(3);
|
||||
expect(records.filter(r => r.method !== 'POST' && r.method !== 'GET')).toEqual([]);
|
||||
|
||||
expect(errors).toEqual([]);
|
||||
expect(closed).toBe(false);
|
||||
await transport.close();
|
||||
expect(closed).toBe(true);
|
||||
} finally {
|
||||
await transport.close();
|
||||
await handle.close();
|
||||
}
|
||||
}
|
||||
|
||||
verifies('transport:standalone:raw-relay', async ({ transport, protocolVersion }: TestArgs) => {
|
||||
if (transport === 'inMemory') {
|
||||
await rawRelayInMemory(protocolVersion);
|
||||
} else if (transport === 'stdio') {
|
||||
await rawRelayStdio(protocolVersion);
|
||||
} else {
|
||||
await rawRelayStreamableHttp(protocolVersion, transport === 'streamableHttpStateless');
|
||||
}
|
||||
});
|
||||
24
test/e2e/scenarios/transport-sse.test.ts
Normal file
24
test/e2e/scenarios/transport-sse.test.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
/**
|
||||
* Self-contained test bodies for the legacy HTTP+SSE transport surface.
|
||||
*
|
||||
* The interaction matrix exercises the legacy transport as the 'sse' column via
|
||||
* wire(); this file holds the SSE-specific surface requirements that are not
|
||||
* per-cell matrix behaviors.
|
||||
*/
|
||||
|
||||
import { expect } from 'vitest';
|
||||
|
||||
import { verifies } from '../helpers/verifies.js';
|
||||
import type { TestArgs } from '../types.js';
|
||||
|
||||
verifies('transport:sse:server-transport', async (_args: TestArgs) => {
|
||||
// The server half of the legacy SSE transport must be on the public server-side surface for SSE deployments to be hosted on the SDK alone.
|
||||
const surfaces = await Promise.all([
|
||||
import('@modelcontextprotocol/server'),
|
||||
import('@modelcontextprotocol/node'),
|
||||
import('@modelcontextprotocol/express')
|
||||
]);
|
||||
const exported = surfaces.flatMap(surface => Object.keys(surface));
|
||||
const sseServerExports = exported.filter(name => /sse/i.test(name) && /server/i.test(name));
|
||||
expect(sseServerExports.length).toBeGreaterThan(0);
|
||||
});
|
||||
Loading…
Reference in a new issue