Relocate tests under /test (#1220)
This commit is contained in:
parent
7655eeb51c
commit
5a9de1c638
4 changed files with 630 additions and 524 deletions
113
test/integration-tests/processCleanup.test.ts
Normal file
113
test/integration-tests/processCleanup.test.ts
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
import path from 'node:path';
|
||||
import { Readable, Writable } from 'node:stream';
|
||||
import { Client } from '../../src/client/index.js';
|
||||
import { StdioClientTransport } from '../../src/client/stdio.js';
|
||||
import { Server } from '../../src/server/index.js';
|
||||
import { StdioServerTransport } from '../../src/server/stdio.js';
|
||||
import { LoggingMessageNotificationSchema } from '../../src/types.js';
|
||||
|
||||
const FIXTURES_DIR = path.resolve(__dirname, '../../src/__fixtures__');
|
||||
|
||||
describe('Process cleanup', () => {
|
||||
vi.setConfig({ testTimeout: 5000 }); // 5 second timeout
|
||||
|
||||
it('server should exit cleanly after closing transport', async () => {
|
||||
const server = new Server(
|
||||
{
|
||||
name: 'test-server',
|
||||
version: '1.0.0'
|
||||
},
|
||||
{
|
||||
capabilities: {}
|
||||
}
|
||||
);
|
||||
|
||||
const mockReadable = new Readable({
|
||||
read() {
|
||||
this.push(null); // signal EOF
|
||||
}
|
||||
}),
|
||||
mockWritable = new Writable({
|
||||
write(chunk, encoding, callback) {
|
||||
callback();
|
||||
}
|
||||
});
|
||||
|
||||
// Attach mock streams to process for the server transport
|
||||
const transport = new StdioServerTransport(mockReadable, mockWritable);
|
||||
await server.connect(transport);
|
||||
|
||||
// Close the transport
|
||||
await transport.close();
|
||||
|
||||
// ensure a proper disposal mock streams
|
||||
mockReadable.destroy();
|
||||
mockWritable.destroy();
|
||||
|
||||
// If we reach here without hanging, the test passes
|
||||
// The test runner will fail if the process hangs
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
|
||||
it('onclose should be called exactly once', async () => {
|
||||
const client = new Client({
|
||||
name: 'test-client',
|
||||
version: '1.0.0'
|
||||
});
|
||||
|
||||
const transport = new StdioClientTransport({
|
||||
command: 'node',
|
||||
args: ['--import', 'tsx', 'testServer.ts'],
|
||||
cwd: FIXTURES_DIR
|
||||
});
|
||||
|
||||
await client.connect(transport);
|
||||
|
||||
let onCloseWasCalled = 0;
|
||||
client.onclose = () => {
|
||||
onCloseWasCalled++;
|
||||
};
|
||||
|
||||
await client.close();
|
||||
|
||||
// A short delay to allow the close event to propagate
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
|
||||
expect(onCloseWasCalled).toBe(1);
|
||||
});
|
||||
|
||||
it('should exit cleanly for a server that hangs', async () => {
|
||||
const client = new Client({
|
||||
name: 'test-client',
|
||||
version: '1.0.0'
|
||||
});
|
||||
|
||||
const transport = new StdioClientTransport({
|
||||
command: 'node',
|
||||
args: ['--import', 'tsx', 'serverThatHangs.ts'],
|
||||
cwd: FIXTURES_DIR
|
||||
});
|
||||
|
||||
await client.connect(transport);
|
||||
await client.setLoggingLevel('debug');
|
||||
client.setNotificationHandler(LoggingMessageNotificationSchema, notification => {
|
||||
console.debug('server log: ' + notification.params.data);
|
||||
});
|
||||
const serverPid = transport.pid!;
|
||||
|
||||
await client.close();
|
||||
|
||||
// A short delay to allow the close event to propagate
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
|
||||
try {
|
||||
process.kill(serverPid, 9);
|
||||
throw new Error('Expected server to be dead but it is alive');
|
||||
} catch (err: unknown) {
|
||||
// 'ESRCH' the process doesn't exist
|
||||
if (err && typeof err === 'object' && 'code' in err && err.code === 'ESRCH') {
|
||||
// success
|
||||
} else throw err;
|
||||
}
|
||||
});
|
||||
});
|
||||
355
test/integration-tests/stateManagementStreamableHttp.test.ts
Normal file
355
test/integration-tests/stateManagementStreamableHttp.test.ts
Normal file
|
|
@ -0,0 +1,355 @@
|
|||
import { createServer, type Server } from 'node:http';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { Client } from '../../src/client/index.js';
|
||||
import { StreamableHTTPClientTransport } from '../../src/client/streamableHttp.js';
|
||||
import { McpServer } from '../../src/server/mcp.js';
|
||||
import { StreamableHTTPServerTransport } from '../../src/server/streamableHttp.js';
|
||||
import {
|
||||
CallToolResultSchema,
|
||||
ListToolsResultSchema,
|
||||
ListResourcesResultSchema,
|
||||
ListPromptsResultSchema,
|
||||
LATEST_PROTOCOL_VERSION
|
||||
} from '../../src/types.js';
|
||||
import { zodTestMatrix, type ZodMatrixEntry } from '../../src/__fixtures__/zodTestMatrix.js';
|
||||
import { listenOnRandomPort } from '../helpers/http.js';
|
||||
|
||||
describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => {
|
||||
const { z } = entry;
|
||||
describe('Streamable HTTP Transport Session Management', () => {
|
||||
// Function to set up the server with optional session management
|
||||
async function setupServer(withSessionManagement: boolean) {
|
||||
const server: Server = createServer();
|
||||
const mcpServer = new McpServer(
|
||||
{ name: 'test-server', version: '1.0.0' },
|
||||
{
|
||||
capabilities: {
|
||||
logging: {},
|
||||
tools: {},
|
||||
resources: {},
|
||||
prompts: {}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Add a simple resource
|
||||
mcpServer.resource('test-resource', '/test', { description: 'A test resource' }, async () => ({
|
||||
contents: [
|
||||
{
|
||||
uri: '/test',
|
||||
text: 'This is a test resource content'
|
||||
}
|
||||
]
|
||||
}));
|
||||
|
||||
mcpServer.prompt('test-prompt', 'A test prompt', async () => ({
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: {
|
||||
type: 'text',
|
||||
text: 'This is a test prompt'
|
||||
}
|
||||
}
|
||||
]
|
||||
}));
|
||||
|
||||
mcpServer.tool(
|
||||
'greet',
|
||||
'A simple greeting tool',
|
||||
{
|
||||
name: z.string().describe('Name to greet').default('World')
|
||||
},
|
||||
async ({ name }) => {
|
||||
return {
|
||||
content: [{ type: 'text', text: `Hello, ${name}!` }]
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
// Create transport with or without session management
|
||||
const serverTransport = new StreamableHTTPServerTransport({
|
||||
sessionIdGenerator: withSessionManagement
|
||||
? () => randomUUID() // With session management, generate UUID
|
||||
: undefined // Without session management, return undefined
|
||||
});
|
||||
|
||||
await mcpServer.connect(serverTransport);
|
||||
|
||||
server.on('request', async (req, res) => {
|
||||
await serverTransport.handleRequest(req, res);
|
||||
});
|
||||
|
||||
// Start the server on a random port
|
||||
const baseUrl = await listenOnRandomPort(server);
|
||||
|
||||
return { server, mcpServer, serverTransport, baseUrl };
|
||||
}
|
||||
|
||||
describe('Stateless Mode', () => {
|
||||
let server: Server;
|
||||
let mcpServer: McpServer;
|
||||
let serverTransport: StreamableHTTPServerTransport;
|
||||
let baseUrl: URL;
|
||||
|
||||
beforeEach(async () => {
|
||||
const setup = await setupServer(false);
|
||||
server = setup.server;
|
||||
mcpServer = setup.mcpServer;
|
||||
serverTransport = setup.serverTransport;
|
||||
baseUrl = setup.baseUrl;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
// Clean up resources
|
||||
await mcpServer.close().catch(() => {});
|
||||
await serverTransport.close().catch(() => {});
|
||||
server.close();
|
||||
});
|
||||
|
||||
it('should support multiple client connections', async () => {
|
||||
// Create and connect a client
|
||||
const client1 = new Client({
|
||||
name: 'test-client',
|
||||
version: '1.0.0'
|
||||
});
|
||||
|
||||
const transport1 = new StreamableHTTPClientTransport(baseUrl);
|
||||
await client1.connect(transport1);
|
||||
|
||||
// Verify that no session ID was set
|
||||
expect(transport1.sessionId).toBeUndefined();
|
||||
|
||||
// List available tools
|
||||
await client1.request(
|
||||
{
|
||||
method: 'tools/list',
|
||||
params: {}
|
||||
},
|
||||
ListToolsResultSchema
|
||||
);
|
||||
|
||||
const client2 = new Client({
|
||||
name: 'test-client',
|
||||
version: '1.0.0'
|
||||
});
|
||||
|
||||
const transport2 = new StreamableHTTPClientTransport(baseUrl);
|
||||
await client2.connect(transport2);
|
||||
|
||||
// Verify that no session ID was set
|
||||
expect(transport2.sessionId).toBeUndefined();
|
||||
|
||||
// List available tools
|
||||
await client2.request(
|
||||
{
|
||||
method: 'tools/list',
|
||||
params: {}
|
||||
},
|
||||
ListToolsResultSchema
|
||||
);
|
||||
});
|
||||
it('should operate without session management', async () => {
|
||||
// Create and connect a client
|
||||
const client = new Client({
|
||||
name: 'test-client',
|
||||
version: '1.0.0'
|
||||
});
|
||||
|
||||
const transport = new StreamableHTTPClientTransport(baseUrl);
|
||||
await client.connect(transport);
|
||||
|
||||
// Verify that no session ID was set
|
||||
expect(transport.sessionId).toBeUndefined();
|
||||
|
||||
// List available tools
|
||||
const toolsResult = await client.request(
|
||||
{
|
||||
method: 'tools/list',
|
||||
params: {}
|
||||
},
|
||||
ListToolsResultSchema
|
||||
);
|
||||
|
||||
// Verify tools are accessible
|
||||
expect(toolsResult.tools).toContainEqual(
|
||||
expect.objectContaining({
|
||||
name: 'greet'
|
||||
})
|
||||
);
|
||||
|
||||
// List available resources
|
||||
const resourcesResult = await client.request(
|
||||
{
|
||||
method: 'resources/list',
|
||||
params: {}
|
||||
},
|
||||
ListResourcesResultSchema
|
||||
);
|
||||
|
||||
// Verify resources result structure
|
||||
expect(resourcesResult).toHaveProperty('resources');
|
||||
|
||||
// List available prompts
|
||||
const promptsResult = await client.request(
|
||||
{
|
||||
method: 'prompts/list',
|
||||
params: {}
|
||||
},
|
||||
ListPromptsResultSchema
|
||||
);
|
||||
|
||||
// Verify prompts result structure
|
||||
expect(promptsResult).toHaveProperty('prompts');
|
||||
expect(promptsResult.prompts).toContainEqual(
|
||||
expect.objectContaining({
|
||||
name: 'test-prompt'
|
||||
})
|
||||
);
|
||||
|
||||
// Call the greeting tool
|
||||
const greetingResult = await client.request(
|
||||
{
|
||||
method: 'tools/call',
|
||||
params: {
|
||||
name: 'greet',
|
||||
arguments: {
|
||||
name: 'Stateless Transport'
|
||||
}
|
||||
}
|
||||
},
|
||||
CallToolResultSchema
|
||||
);
|
||||
|
||||
// Verify tool result
|
||||
expect(greetingResult.content).toEqual([{ type: 'text', text: 'Hello, Stateless Transport!' }]);
|
||||
|
||||
// Clean up
|
||||
await transport.close();
|
||||
});
|
||||
|
||||
it('should set protocol version after connecting', async () => {
|
||||
// Create and connect a client
|
||||
const client = new Client({
|
||||
name: 'test-client',
|
||||
version: '1.0.0'
|
||||
});
|
||||
|
||||
const transport = new StreamableHTTPClientTransport(baseUrl);
|
||||
|
||||
// Verify protocol version is not set before connecting
|
||||
expect(transport.protocolVersion).toBeUndefined();
|
||||
|
||||
await client.connect(transport);
|
||||
|
||||
// Verify protocol version is set after connecting
|
||||
expect(transport.protocolVersion).toBe(LATEST_PROTOCOL_VERSION);
|
||||
|
||||
// Clean up
|
||||
await transport.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Stateful Mode', () => {
|
||||
let server: Server;
|
||||
let mcpServer: McpServer;
|
||||
let serverTransport: StreamableHTTPServerTransport;
|
||||
let baseUrl: URL;
|
||||
|
||||
beforeEach(async () => {
|
||||
const setup = await setupServer(true);
|
||||
server = setup.server;
|
||||
mcpServer = setup.mcpServer;
|
||||
serverTransport = setup.serverTransport;
|
||||
baseUrl = setup.baseUrl;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
// Clean up resources
|
||||
await mcpServer.close().catch(() => {});
|
||||
await serverTransport.close().catch(() => {});
|
||||
server.close();
|
||||
});
|
||||
|
||||
it('should operate with session management', async () => {
|
||||
// Create and connect a client
|
||||
const client = new Client({
|
||||
name: 'test-client',
|
||||
version: '1.0.0'
|
||||
});
|
||||
|
||||
const transport = new StreamableHTTPClientTransport(baseUrl);
|
||||
await client.connect(transport);
|
||||
|
||||
// Verify that a session ID was set
|
||||
expect(transport.sessionId).toBeDefined();
|
||||
expect(typeof transport.sessionId).toBe('string');
|
||||
|
||||
// List available tools
|
||||
const toolsResult = await client.request(
|
||||
{
|
||||
method: 'tools/list',
|
||||
params: {}
|
||||
},
|
||||
ListToolsResultSchema
|
||||
);
|
||||
|
||||
// Verify tools are accessible
|
||||
expect(toolsResult.tools).toContainEqual(
|
||||
expect.objectContaining({
|
||||
name: 'greet'
|
||||
})
|
||||
);
|
||||
|
||||
// List available resources
|
||||
const resourcesResult = await client.request(
|
||||
{
|
||||
method: 'resources/list',
|
||||
params: {}
|
||||
},
|
||||
ListResourcesResultSchema
|
||||
);
|
||||
|
||||
// Verify resources result structure
|
||||
expect(resourcesResult).toHaveProperty('resources');
|
||||
|
||||
// List available prompts
|
||||
const promptsResult = await client.request(
|
||||
{
|
||||
method: 'prompts/list',
|
||||
params: {}
|
||||
},
|
||||
ListPromptsResultSchema
|
||||
);
|
||||
|
||||
// Verify prompts result structure
|
||||
expect(promptsResult).toHaveProperty('prompts');
|
||||
expect(promptsResult.prompts).toContainEqual(
|
||||
expect.objectContaining({
|
||||
name: 'test-prompt'
|
||||
})
|
||||
);
|
||||
|
||||
// Call the greeting tool
|
||||
const greetingResult = await client.request(
|
||||
{
|
||||
method: 'tools/call',
|
||||
params: {
|
||||
name: 'greet',
|
||||
arguments: {
|
||||
name: 'Stateful Transport'
|
||||
}
|
||||
}
|
||||
},
|
||||
CallToolResultSchema
|
||||
);
|
||||
|
||||
// Verify tool result
|
||||
expect(greetingResult.content).toEqual([{ type: 'text', text: 'Hello, Stateful Transport!' }]);
|
||||
|
||||
// Clean up
|
||||
await transport.close();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
1664
test/integration-tests/taskLifecycle.test.ts
Normal file
1664
test/integration-tests/taskLifecycle.test.ts
Normal file
File diff suppressed because it is too large
Load diff
264
test/integration-tests/taskResumability.test.ts
Normal file
264
test/integration-tests/taskResumability.test.ts
Normal file
|
|
@ -0,0 +1,264 @@
|
|||
import { createServer, type Server } from 'node:http';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { Client } from '../../src/client/index.js';
|
||||
import { StreamableHTTPClientTransport } from '../../src/client/streamableHttp.js';
|
||||
import { McpServer } from '../../src/server/mcp.js';
|
||||
import { StreamableHTTPServerTransport } from '../../src/server/streamableHttp.js';
|
||||
import { CallToolResultSchema, LoggingMessageNotificationSchema } from '../../src/types.js';
|
||||
import { InMemoryEventStore } from '../../src/examples/shared/inMemoryEventStore.js';
|
||||
import { zodTestMatrix, type ZodMatrixEntry } from '../../src/__fixtures__/zodTestMatrix.js';
|
||||
import { listenOnRandomPort } from '../helpers/http.js';
|
||||
|
||||
describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => {
|
||||
const { z } = entry;
|
||||
describe('Transport resumability', () => {
|
||||
let server: Server;
|
||||
let mcpServer: McpServer;
|
||||
let serverTransport: StreamableHTTPServerTransport;
|
||||
let baseUrl: URL;
|
||||
let eventStore: InMemoryEventStore;
|
||||
|
||||
beforeEach(async () => {
|
||||
// Create event store for resumability
|
||||
eventStore = new InMemoryEventStore();
|
||||
|
||||
// Create a simple MCP server
|
||||
mcpServer = new McpServer({ name: 'test-server', version: '1.0.0' }, { capabilities: { logging: {} } });
|
||||
|
||||
// Add a simple notification tool that completes quickly
|
||||
mcpServer.tool(
|
||||
'send-notification',
|
||||
'Sends a single notification',
|
||||
{
|
||||
message: z.string().describe('Message to send').default('Test notification')
|
||||
},
|
||||
async ({ message }, { sendNotification }) => {
|
||||
// Send notification immediately
|
||||
await sendNotification({
|
||||
method: 'notifications/message',
|
||||
params: {
|
||||
level: 'info',
|
||||
data: message
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: 'Notification sent' }]
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
// Add a long-running tool that sends multiple notifications
|
||||
mcpServer.tool(
|
||||
'run-notifications',
|
||||
'Sends multiple notifications over time',
|
||||
{
|
||||
count: z.number().describe('Number of notifications to send').default(10),
|
||||
interval: z.number().describe('Interval between notifications in ms').default(50)
|
||||
},
|
||||
async ({ count, interval }, { sendNotification }) => {
|
||||
// Send notifications at specified intervals
|
||||
for (let i = 0; i < count; i++) {
|
||||
await sendNotification({
|
||||
method: 'notifications/message',
|
||||
params: {
|
||||
level: 'info',
|
||||
data: `Notification ${i + 1} of ${count}`
|
||||
}
|
||||
});
|
||||
|
||||
// Wait for the specified interval before sending next notification
|
||||
if (i < count - 1) {
|
||||
await new Promise(resolve => setTimeout(resolve, interval));
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: `Sent ${count} notifications` }]
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
// Create a transport with the event store
|
||||
serverTransport = new StreamableHTTPServerTransport({
|
||||
sessionIdGenerator: () => randomUUID(),
|
||||
eventStore
|
||||
});
|
||||
|
||||
// Connect the transport to the MCP server
|
||||
await mcpServer.connect(serverTransport);
|
||||
|
||||
// Create and start an HTTP server
|
||||
server = createServer(async (req, res) => {
|
||||
await serverTransport.handleRequest(req, res);
|
||||
});
|
||||
|
||||
// Start the server on a random port
|
||||
baseUrl = await listenOnRandomPort(server);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
// Clean up resources
|
||||
await mcpServer.close().catch(() => {});
|
||||
await serverTransport.close().catch(() => {});
|
||||
server.close();
|
||||
});
|
||||
|
||||
it('should store session ID when client connects', async () => {
|
||||
// Create and connect a client
|
||||
const client = new Client({
|
||||
name: 'test-client',
|
||||
version: '1.0.0'
|
||||
});
|
||||
|
||||
const transport = new StreamableHTTPClientTransport(baseUrl);
|
||||
await client.connect(transport);
|
||||
|
||||
// Verify session ID was generated
|
||||
expect(transport.sessionId).toBeDefined();
|
||||
|
||||
// Clean up
|
||||
await transport.close();
|
||||
});
|
||||
|
||||
it('should have session ID functionality', async () => {
|
||||
// The ability to store a session ID when connecting
|
||||
const client = new Client({
|
||||
name: 'test-client-reconnection',
|
||||
version: '1.0.0'
|
||||
});
|
||||
|
||||
const transport = new StreamableHTTPClientTransport(baseUrl);
|
||||
|
||||
// Make sure the client can connect and get a session ID
|
||||
await client.connect(transport);
|
||||
expect(transport.sessionId).toBeDefined();
|
||||
|
||||
// Clean up
|
||||
await transport.close();
|
||||
});
|
||||
|
||||
// This test demonstrates the capability to resume long-running tools
|
||||
// across client disconnection/reconnection
|
||||
it('should resume long-running notifications with lastEventId', async () => {
|
||||
// Create unique client ID for this test
|
||||
const clientTitle = 'test-client-long-running';
|
||||
const notifications = [];
|
||||
let lastEventId: string | undefined;
|
||||
|
||||
// Create first client
|
||||
const client1 = new Client({
|
||||
title: clientTitle,
|
||||
name: 'test-client',
|
||||
version: '1.0.0'
|
||||
});
|
||||
|
||||
// Set up notification handler for first client
|
||||
client1.setNotificationHandler(LoggingMessageNotificationSchema, notification => {
|
||||
if (notification.method === 'notifications/message') {
|
||||
notifications.push(notification.params);
|
||||
}
|
||||
});
|
||||
|
||||
// Connect first client
|
||||
const transport1 = new StreamableHTTPClientTransport(baseUrl);
|
||||
await client1.connect(transport1);
|
||||
const sessionId = transport1.sessionId;
|
||||
expect(sessionId).toBeDefined();
|
||||
|
||||
// Start a long-running notification stream with tracking of lastEventId
|
||||
const onLastEventIdUpdate = vi.fn((eventId: string) => {
|
||||
lastEventId = eventId;
|
||||
});
|
||||
expect(lastEventId).toBeUndefined();
|
||||
// Start the notification tool with event tracking using request
|
||||
const toolPromise = client1.request(
|
||||
{
|
||||
method: 'tools/call',
|
||||
params: {
|
||||
name: 'run-notifications',
|
||||
arguments: {
|
||||
count: 3,
|
||||
interval: 10
|
||||
}
|
||||
}
|
||||
},
|
||||
CallToolResultSchema,
|
||||
{
|
||||
resumptionToken: lastEventId,
|
||||
onresumptiontoken: onLastEventIdUpdate
|
||||
}
|
||||
);
|
||||
|
||||
// Fix for node 18 test failures, allow some time for notifications to arrive
|
||||
const maxWaitTime = 2000; // 2 seconds max wait
|
||||
const pollInterval = 10; // Check every 10ms
|
||||
const startTime = Date.now();
|
||||
while (notifications.length === 0 && Date.now() - startTime < maxWaitTime) {
|
||||
// Wait for some notifications to arrive (not all) - shorter wait time
|
||||
await new Promise(resolve => setTimeout(resolve, pollInterval));
|
||||
}
|
||||
|
||||
// Verify we received some notifications and lastEventId was updated
|
||||
expect(notifications.length).toBeGreaterThan(0);
|
||||
expect(notifications.length).toBeLessThan(4);
|
||||
expect(onLastEventIdUpdate).toHaveBeenCalled();
|
||||
expect(lastEventId).toBeDefined();
|
||||
|
||||
// Disconnect first client without waiting for completion
|
||||
// When we close the connection, it will cause a ConnectionClosed error for
|
||||
// any in-progress requests, which is expected behavior
|
||||
await transport1.close();
|
||||
// Save the promise so we can catch it after closing
|
||||
const catchPromise = toolPromise.catch(err => {
|
||||
// This error is expected - the connection was intentionally closed
|
||||
if (err?.code !== -32000) {
|
||||
// ConnectionClosed error code
|
||||
console.error('Unexpected error type during transport close:', err);
|
||||
}
|
||||
});
|
||||
|
||||
// Add a short delay to ensure clean disconnect before reconnecting
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
|
||||
// Wait for the rejection to be handled
|
||||
await catchPromise;
|
||||
|
||||
// Create second client with same client ID
|
||||
const client2 = new Client({
|
||||
title: clientTitle,
|
||||
name: 'test-client',
|
||||
version: '1.0.0'
|
||||
});
|
||||
|
||||
// Track replayed notifications separately
|
||||
const replayedNotifications: unknown[] = [];
|
||||
client2.setNotificationHandler(LoggingMessageNotificationSchema, notification => {
|
||||
if (notification.method === 'notifications/message') {
|
||||
replayedNotifications.push(notification.params);
|
||||
}
|
||||
});
|
||||
|
||||
// Connect second client with same session ID
|
||||
const transport2 = new StreamableHTTPClientTransport(baseUrl, {
|
||||
sessionId
|
||||
});
|
||||
await client2.connect(transport2);
|
||||
|
||||
// Resume GET SSE stream with Last-Event-ID to replay missed events
|
||||
// Per spec, resumption uses GET with Last-Event-ID header
|
||||
await transport2.resumeStream(lastEventId!, { onresumptiontoken: onLastEventIdUpdate });
|
||||
|
||||
// Wait for replayed events to arrive via SSE
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
// Verify the test infrastructure worked - we received notifications in first session
|
||||
// and captured the lastEventId for potential replay
|
||||
expect(notifications.length).toBeGreaterThan(0);
|
||||
expect(lastEventId).toBeDefined();
|
||||
|
||||
// Clean up
|
||||
await transport2.close();
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue