|
1 | 1 | import { describe, expect, it } from 'vitest'; |
2 | 2 | import express from 'express'; |
| 3 | +import { once } from 'node:events'; |
| 4 | +import { createServer } from 'node:http'; |
3 | 5 | import { InMemoryEventStore, InMemoryTelemetryRecorder } from '@codesoul-co/hypha-core'; |
4 | 6 | import { |
5 | 7 | GovernedToolRunner, |
@@ -40,6 +42,85 @@ import { |
40 | 42 | type MCPServerProfile, |
41 | 43 | } from './index'; |
42 | 44 |
|
| 45 | +async function createInitializationFixture(responseDelayMs = 0): Promise<{ |
| 46 | + endpoint: string; |
| 47 | + initializeRequests: () => number; |
| 48 | + waitForInitialize: () => Promise<void>; |
| 49 | + close: () => Promise<void>; |
| 50 | +}> { |
| 51 | + let initializeRequests = 0; |
| 52 | + let notifyInitialize: (() => void) | undefined; |
| 53 | + const initializeReceived = new Promise<void>((resolve) => { |
| 54 | + notifyInitialize = resolve; |
| 55 | + }); |
| 56 | + const httpServer = createServer(async (request, response) => { |
| 57 | + if (request.method !== 'POST') { |
| 58 | + response.writeHead(405).end(); |
| 59 | + return; |
| 60 | + } |
| 61 | + const chunks: Buffer[] = []; |
| 62 | + for await (const chunk of request) { |
| 63 | + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); |
| 64 | + } |
| 65 | + const message = JSON.parse(Buffer.concat(chunks).toString()) as { |
| 66 | + id?: string | number; |
| 67 | + method?: string; |
| 68 | + }; |
| 69 | + if (message.method !== 'initialize') { |
| 70 | + response.writeHead(202).end(); |
| 71 | + return; |
| 72 | + } |
| 73 | + initializeRequests += 1; |
| 74 | + notifyInitialize?.(); |
| 75 | + if (responseDelayMs > 0) { |
| 76 | + await new Promise((resolve) => setTimeout(resolve, responseDelayMs)); |
| 77 | + } |
| 78 | + if (response.destroyed) return; |
| 79 | + response.writeHead(200, { 'content-type': 'application/json' }); |
| 80 | + response.end( |
| 81 | + JSON.stringify({ |
| 82 | + jsonrpc: '2.0', |
| 83 | + id: message.id, |
| 84 | + result: { |
| 85 | + protocolVersion: '2025-11-25', |
| 86 | + capabilities: {}, |
| 87 | + serverInfo: { name: 'abort-fixture', version: '1.0.0' }, |
| 88 | + }, |
| 89 | + }) |
| 90 | + ); |
| 91 | + }); |
| 92 | + httpServer.listen(0, '127.0.0.1'); |
| 93 | + await once(httpServer, 'listening'); |
| 94 | + const address = httpServer.address(); |
| 95 | + if (!address || typeof address === 'string') { |
| 96 | + throw new Error('Initialization fixture server has no port.'); |
| 97 | + } |
| 98 | + return { |
| 99 | + endpoint: `http://127.0.0.1:${address.port}/mcp`, |
| 100 | + initializeRequests: () => initializeRequests, |
| 101 | + waitForInitialize: () => initializeReceived, |
| 102 | + close: async () => { |
| 103 | + httpServer.closeAllConnections(); |
| 104 | + await new Promise<void>((resolve, reject) => |
| 105 | + httpServer.close((error) => (error ? reject(error) : resolve())) |
| 106 | + ); |
| 107 | + }, |
| 108 | + }; |
| 109 | +} |
| 110 | + |
| 111 | +function createSDKHTTPConnectionSession(endpoint: string): MCPConnectionSession { |
| 112 | + return new SDKMCPConnectionSessionFactory().create({ |
| 113 | + id: 'abort-fixture', |
| 114 | + mode: 'fixture', |
| 115 | + transport: { type: 'streamable_http', endpoint }, |
| 116 | + egressPolicy: { |
| 117 | + allowedHosts: ['127.0.0.1'], |
| 118 | + denyPrivateNetworks: false, |
| 119 | + requireTls: false, |
| 120 | + }, |
| 121 | + }); |
| 122 | +} |
| 123 | + |
43 | 124 | describe('@codesoul-co/hypha-mcp normalization', () => { |
44 | 125 | it('unwraps structured Tool output without misclassifying domain objects', () => { |
45 | 126 | expect( |
@@ -1331,6 +1412,57 @@ describe('@codesoul-co/hypha-mcp normalization', () => { |
1331 | 1412 | await expect(manager.get('stdio-fixture')).resolves.toMatchObject({ state: 'closed' }); |
1332 | 1413 | }); |
1333 | 1414 |
|
| 1415 | + it('rejects an already-aborted signal before SDK initialization starts', async () => { |
| 1416 | + const fixture = await createInitializationFixture(); |
| 1417 | + const session = createSDKHTTPConnectionSession(fixture.endpoint); |
| 1418 | + const controller = new AbortController(); |
| 1419 | + controller.abort(new Error('cancelled before connect')); |
| 1420 | + |
| 1421 | + try { |
| 1422 | + await expect(session.connect(controller.signal)).rejects.toThrow('cancelled before connect'); |
| 1423 | + expect(fixture.initializeRequests()).toBe(0); |
| 1424 | + } finally { |
| 1425 | + await session.close().catch(() => undefined); |
| 1426 | + await fixture.close(); |
| 1427 | + } |
| 1428 | + }); |
| 1429 | + |
| 1430 | + it('aborts and cleans up SDK initialization while the response is pending', async () => { |
| 1431 | + const fixture = await createInitializationFixture(150); |
| 1432 | + const session = createSDKHTTPConnectionSession(fixture.endpoint); |
| 1433 | + const controller = new AbortController(); |
| 1434 | + const connecting = session.connect(controller.signal); |
| 1435 | + |
| 1436 | + try { |
| 1437 | + await fixture.waitForInitialize(); |
| 1438 | + controller.abort(new Error('cancelled during connect')); |
| 1439 | + await expect(connecting).rejects.toThrow('cancelled during connect'); |
| 1440 | + expect(fixture.initializeRequests()).toBe(1); |
| 1441 | + await expect(session.connect()).resolves.toMatchObject({ |
| 1442 | + negotiatedProtocolVersion: '2025-11-25', |
| 1443 | + }); |
| 1444 | + } finally { |
| 1445 | + await session.close().catch(() => undefined); |
| 1446 | + await fixture.close(); |
| 1447 | + } |
| 1448 | + }); |
| 1449 | + |
| 1450 | + it('preserves SDK initialization when no cancellation signal is provided', async () => { |
| 1451 | + const fixture = await createInitializationFixture(); |
| 1452 | + const session = createSDKHTTPConnectionSession(fixture.endpoint); |
| 1453 | + |
| 1454 | + try { |
| 1455 | + await expect(session.connect()).resolves.toMatchObject({ |
| 1456 | + negotiatedProtocolVersion: '2025-11-25', |
| 1457 | + serverInfo: { name: 'abort-fixture', version: '1.0.0' }, |
| 1458 | + }); |
| 1459 | + expect(fixture.initializeRequests()).toBe(1); |
| 1460 | + } finally { |
| 1461 | + await session.close().catch(() => undefined); |
| 1462 | + await fixture.close(); |
| 1463 | + } |
| 1464 | + }); |
| 1465 | + |
1334 | 1466 | it('connects through the real MCP Streamable HTTP transport', async () => { |
1335 | 1467 | const fixtureErrors: string[] = []; |
1336 | 1468 | const createProtocolServer = () => { |
|
0 commit comments