streamableHttp.js 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477
  1. import { createFetchWithInit, normalizeHeaders } from '../shared/transport.js';
  2. import { isInitializedNotification, isJSONRPCRequest, isJSONRPCResultResponse, JSONRPCMessageSchema } from '../types.js';
  3. import { auth, extractWWWAuthenticateParams, UnauthorizedError } from './auth.js';
  4. import { EventSourceParserStream } from 'eventsource-parser/stream';
  5. // Default reconnection options for StreamableHTTP connections
  6. const DEFAULT_STREAMABLE_HTTP_RECONNECTION_OPTIONS = {
  7. initialReconnectionDelay: 1000,
  8. maxReconnectionDelay: 30000,
  9. reconnectionDelayGrowFactor: 1.5,
  10. maxRetries: 2
  11. };
  12. export class StreamableHTTPError extends Error {
  13. constructor(code, message) {
  14. super(`Streamable HTTP error: ${message}`);
  15. this.code = code;
  16. }
  17. }
  18. /**
  19. * Client transport for Streamable HTTP: this implements the MCP Streamable HTTP transport specification.
  20. * It will connect to a server using HTTP POST for sending messages and HTTP GET with Server-Sent Events
  21. * for receiving messages.
  22. */
  23. export class StreamableHTTPClientTransport {
  24. constructor(url, opts) {
  25. this._hasCompletedAuthFlow = false; // Circuit breaker: detect auth success followed by immediate 401
  26. this._url = url;
  27. this._resourceMetadataUrl = undefined;
  28. this._scope = undefined;
  29. this._requestInit = opts?.requestInit;
  30. this._authProvider = opts?.authProvider;
  31. this._fetch = opts?.fetch;
  32. this._fetchWithInit = createFetchWithInit(opts?.fetch, opts?.requestInit);
  33. this._sessionId = opts?.sessionId;
  34. this._reconnectionOptions = opts?.reconnectionOptions ?? DEFAULT_STREAMABLE_HTTP_RECONNECTION_OPTIONS;
  35. }
  36. async _authThenStart() {
  37. if (!this._authProvider) {
  38. throw new UnauthorizedError('No auth provider');
  39. }
  40. let result;
  41. try {
  42. result = await auth(this._authProvider, {
  43. serverUrl: this._url,
  44. resourceMetadataUrl: this._resourceMetadataUrl,
  45. scope: this._scope,
  46. fetchFn: this._fetchWithInit
  47. });
  48. }
  49. catch (error) {
  50. this.onerror?.(error);
  51. throw error;
  52. }
  53. if (result !== 'AUTHORIZED') {
  54. throw new UnauthorizedError();
  55. }
  56. return await this._startOrAuthSse({ resumptionToken: undefined });
  57. }
  58. async _commonHeaders() {
  59. const headers = {};
  60. if (this._authProvider) {
  61. const tokens = await this._authProvider.tokens();
  62. if (tokens) {
  63. headers['Authorization'] = `Bearer ${tokens.access_token}`;
  64. }
  65. }
  66. if (this._sessionId) {
  67. headers['mcp-session-id'] = this._sessionId;
  68. }
  69. if (this._protocolVersion) {
  70. headers['mcp-protocol-version'] = this._protocolVersion;
  71. }
  72. const extraHeaders = normalizeHeaders(this._requestInit?.headers);
  73. return new Headers({
  74. ...headers,
  75. ...extraHeaders
  76. });
  77. }
  78. async _startOrAuthSse(options) {
  79. const { resumptionToken } = options;
  80. try {
  81. // Try to open an initial SSE stream with GET to listen for server messages
  82. // This is optional according to the spec - server may not support it
  83. const headers = await this._commonHeaders();
  84. headers.set('Accept', 'text/event-stream');
  85. // Include Last-Event-ID header for resumable streams if provided
  86. if (resumptionToken) {
  87. headers.set('last-event-id', resumptionToken);
  88. }
  89. const response = await (this._fetch ?? fetch)(this._url, {
  90. method: 'GET',
  91. headers,
  92. signal: this._abortController?.signal
  93. });
  94. if (!response.ok) {
  95. await response.body?.cancel();
  96. if (response.status === 401 && this._authProvider) {
  97. // Need to authenticate
  98. return await this._authThenStart();
  99. }
  100. // 405 indicates that the server does not offer an SSE stream at GET endpoint
  101. // This is an expected case that should not trigger an error
  102. if (response.status === 405) {
  103. return;
  104. }
  105. throw new StreamableHTTPError(response.status, `Failed to open SSE stream: ${response.statusText}`);
  106. }
  107. this._handleSseStream(response.body, options, true);
  108. }
  109. catch (error) {
  110. this.onerror?.(error);
  111. throw error;
  112. }
  113. }
  114. /**
  115. * Calculates the next reconnection delay using backoff algorithm
  116. *
  117. * @param attempt Current reconnection attempt count for the specific stream
  118. * @returns Time to wait in milliseconds before next reconnection attempt
  119. */
  120. _getNextReconnectionDelay(attempt) {
  121. // Use server-provided retry value if available
  122. if (this._serverRetryMs !== undefined) {
  123. return this._serverRetryMs;
  124. }
  125. // Fall back to exponential backoff
  126. const initialDelay = this._reconnectionOptions.initialReconnectionDelay;
  127. const growFactor = this._reconnectionOptions.reconnectionDelayGrowFactor;
  128. const maxDelay = this._reconnectionOptions.maxReconnectionDelay;
  129. // Cap at maximum delay
  130. return Math.min(initialDelay * Math.pow(growFactor, attempt), maxDelay);
  131. }
  132. /**
  133. * Schedule a reconnection attempt using server-provided retry interval or backoff
  134. *
  135. * @param lastEventId The ID of the last received event for resumability
  136. * @param attemptCount Current reconnection attempt count for this specific stream
  137. */
  138. _scheduleReconnection(options, attemptCount = 0) {
  139. // Use provided options or default options
  140. const maxRetries = this._reconnectionOptions.maxRetries;
  141. // Check if we've exceeded maximum retry attempts
  142. if (attemptCount >= maxRetries) {
  143. this.onerror?.(new Error(`Maximum reconnection attempts (${maxRetries}) exceeded.`));
  144. return;
  145. }
  146. // Calculate next delay based on current attempt count
  147. const delay = this._getNextReconnectionDelay(attemptCount);
  148. // Schedule the reconnection
  149. this._reconnectionTimeout = setTimeout(() => {
  150. // Use the last event ID to resume where we left off
  151. this._startOrAuthSse(options).catch(error => {
  152. this.onerror?.(new Error(`Failed to reconnect SSE stream: ${error instanceof Error ? error.message : String(error)}`));
  153. // Schedule another attempt if this one failed, incrementing the attempt counter
  154. this._scheduleReconnection(options, attemptCount + 1);
  155. });
  156. }, delay);
  157. }
  158. _handleSseStream(stream, options, isReconnectable) {
  159. if (!stream) {
  160. return;
  161. }
  162. const { onresumptiontoken, replayMessageId } = options;
  163. let lastEventId;
  164. // Track whether we've received a priming event (event with ID)
  165. // Per spec, server SHOULD send a priming event with ID before closing
  166. let hasPrimingEvent = false;
  167. // Track whether we've received a response - if so, no need to reconnect
  168. // Reconnection is for when server disconnects BEFORE sending response
  169. let receivedResponse = false;
  170. const processStream = async () => {
  171. // this is the closest we can get to trying to catch network errors
  172. // if something happens reader will throw
  173. try {
  174. // Create a pipeline: binary stream -> text decoder -> SSE parser
  175. const reader = stream
  176. .pipeThrough(new TextDecoderStream())
  177. .pipeThrough(new EventSourceParserStream({
  178. onRetry: (retryMs) => {
  179. // Capture server-provided retry value for reconnection timing
  180. this._serverRetryMs = retryMs;
  181. }
  182. }))
  183. .getReader();
  184. while (true) {
  185. const { value: event, done } = await reader.read();
  186. if (done) {
  187. break;
  188. }
  189. // Update last event ID if provided
  190. if (event.id) {
  191. lastEventId = event.id;
  192. // Mark that we've received a priming event - stream is now resumable
  193. hasPrimingEvent = true;
  194. onresumptiontoken?.(event.id);
  195. }
  196. // Skip events with no data (priming events, keep-alives)
  197. if (!event.data) {
  198. continue;
  199. }
  200. if (!event.event || event.event === 'message') {
  201. try {
  202. const message = JSONRPCMessageSchema.parse(JSON.parse(event.data));
  203. if (isJSONRPCResultResponse(message)) {
  204. // Mark that we received a response - no need to reconnect for this request
  205. receivedResponse = true;
  206. if (replayMessageId !== undefined) {
  207. message.id = replayMessageId;
  208. }
  209. }
  210. this.onmessage?.(message);
  211. }
  212. catch (error) {
  213. this.onerror?.(error);
  214. }
  215. }
  216. }
  217. // Handle graceful server-side disconnect
  218. // Server may close connection after sending event ID and retry field
  219. // Reconnect if: already reconnectable (GET stream) OR received a priming event (POST stream with event ID)
  220. // BUT don't reconnect if we already received a response - the request is complete
  221. const canResume = isReconnectable || hasPrimingEvent;
  222. const needsReconnect = canResume && !receivedResponse;
  223. if (needsReconnect && this._abortController && !this._abortController.signal.aborted) {
  224. this._scheduleReconnection({
  225. resumptionToken: lastEventId,
  226. onresumptiontoken,
  227. replayMessageId
  228. }, 0);
  229. }
  230. }
  231. catch (error) {
  232. // Handle stream errors - likely a network disconnect
  233. this.onerror?.(new Error(`SSE stream disconnected: ${error}`));
  234. // Attempt to reconnect if the stream disconnects unexpectedly and we aren't closing
  235. // Reconnect if: already reconnectable (GET stream) OR received a priming event (POST stream with event ID)
  236. // BUT don't reconnect if we already received a response - the request is complete
  237. const canResume = isReconnectable || hasPrimingEvent;
  238. const needsReconnect = canResume && !receivedResponse;
  239. if (needsReconnect && this._abortController && !this._abortController.signal.aborted) {
  240. // Use the exponential backoff reconnection strategy
  241. try {
  242. this._scheduleReconnection({
  243. resumptionToken: lastEventId,
  244. onresumptiontoken,
  245. replayMessageId
  246. }, 0);
  247. }
  248. catch (error) {
  249. this.onerror?.(new Error(`Failed to reconnect: ${error instanceof Error ? error.message : String(error)}`));
  250. }
  251. }
  252. }
  253. };
  254. processStream();
  255. }
  256. async start() {
  257. if (this._abortController) {
  258. throw new Error('StreamableHTTPClientTransport already started! If using Client class, note that connect() calls start() automatically.');
  259. }
  260. this._abortController = new AbortController();
  261. }
  262. /**
  263. * Call this method after the user has finished authorizing via their user agent and is redirected back to the MCP client application. This will exchange the authorization code for an access token, enabling the next connection attempt to successfully auth.
  264. */
  265. async finishAuth(authorizationCode) {
  266. if (!this._authProvider) {
  267. throw new UnauthorizedError('No auth provider');
  268. }
  269. const result = await auth(this._authProvider, {
  270. serverUrl: this._url,
  271. authorizationCode,
  272. resourceMetadataUrl: this._resourceMetadataUrl,
  273. scope: this._scope,
  274. fetchFn: this._fetchWithInit
  275. });
  276. if (result !== 'AUTHORIZED') {
  277. throw new UnauthorizedError('Failed to authorize');
  278. }
  279. }
  280. async close() {
  281. if (this._reconnectionTimeout) {
  282. clearTimeout(this._reconnectionTimeout);
  283. this._reconnectionTimeout = undefined;
  284. }
  285. this._abortController?.abort();
  286. this.onclose?.();
  287. }
  288. async send(message, options) {
  289. try {
  290. const { resumptionToken, onresumptiontoken } = options || {};
  291. if (resumptionToken) {
  292. // If we have at last event ID, we need to reconnect the SSE stream
  293. this._startOrAuthSse({ resumptionToken, replayMessageId: isJSONRPCRequest(message) ? message.id : undefined }).catch(err => this.onerror?.(err));
  294. return;
  295. }
  296. const headers = await this._commonHeaders();
  297. headers.set('content-type', 'application/json');
  298. headers.set('accept', 'application/json, text/event-stream');
  299. const init = {
  300. ...this._requestInit,
  301. method: 'POST',
  302. headers,
  303. body: JSON.stringify(message),
  304. signal: this._abortController?.signal
  305. };
  306. const response = await (this._fetch ?? fetch)(this._url, init);
  307. // Handle session ID received during initialization
  308. const sessionId = response.headers.get('mcp-session-id');
  309. if (sessionId) {
  310. this._sessionId = sessionId;
  311. }
  312. if (!response.ok) {
  313. const text = await response.text().catch(() => null);
  314. if (response.status === 401 && this._authProvider) {
  315. // Prevent infinite recursion when server returns 401 after successful auth
  316. if (this._hasCompletedAuthFlow) {
  317. throw new StreamableHTTPError(401, 'Server returned 401 after successful authentication');
  318. }
  319. const { resourceMetadataUrl, scope } = extractWWWAuthenticateParams(response);
  320. this._resourceMetadataUrl = resourceMetadataUrl;
  321. this._scope = scope;
  322. const result = await auth(this._authProvider, {
  323. serverUrl: this._url,
  324. resourceMetadataUrl: this._resourceMetadataUrl,
  325. scope: this._scope,
  326. fetchFn: this._fetchWithInit
  327. });
  328. if (result !== 'AUTHORIZED') {
  329. throw new UnauthorizedError();
  330. }
  331. // Mark that we completed auth flow
  332. this._hasCompletedAuthFlow = true;
  333. // Purposely _not_ awaited, so we don't call onerror twice
  334. return this.send(message);
  335. }
  336. if (response.status === 403 && this._authProvider) {
  337. const { resourceMetadataUrl, scope, error } = extractWWWAuthenticateParams(response);
  338. if (error === 'insufficient_scope') {
  339. const wwwAuthHeader = response.headers.get('WWW-Authenticate');
  340. // Check if we've already tried upscoping with this header to prevent infinite loops.
  341. if (this._lastUpscopingHeader === wwwAuthHeader) {
  342. throw new StreamableHTTPError(403, 'Server returned 403 after trying upscoping');
  343. }
  344. if (scope) {
  345. this._scope = scope;
  346. }
  347. if (resourceMetadataUrl) {
  348. this._resourceMetadataUrl = resourceMetadataUrl;
  349. }
  350. // Mark that upscoping was tried.
  351. this._lastUpscopingHeader = wwwAuthHeader ?? undefined;
  352. const result = await auth(this._authProvider, {
  353. serverUrl: this._url,
  354. resourceMetadataUrl: this._resourceMetadataUrl,
  355. scope: this._scope,
  356. fetchFn: this._fetch
  357. });
  358. if (result !== 'AUTHORIZED') {
  359. throw new UnauthorizedError();
  360. }
  361. return this.send(message);
  362. }
  363. }
  364. throw new StreamableHTTPError(response.status, `Error POSTing to endpoint: ${text}`);
  365. }
  366. // Reset auth loop flag on successful response
  367. this._hasCompletedAuthFlow = false;
  368. this._lastUpscopingHeader = undefined;
  369. // If the response is 202 Accepted, there's no body to process
  370. if (response.status === 202) {
  371. await response.body?.cancel();
  372. // if the accepted notification is initialized, we start the SSE stream
  373. // if it's supported by the server
  374. if (isInitializedNotification(message)) {
  375. // Start without a lastEventId since this is a fresh connection
  376. this._startOrAuthSse({ resumptionToken: undefined }).catch(err => this.onerror?.(err));
  377. }
  378. return;
  379. }
  380. // Get original message(s) for detecting request IDs
  381. const messages = Array.isArray(message) ? message : [message];
  382. const hasRequests = messages.filter(msg => 'method' in msg && 'id' in msg && msg.id !== undefined).length > 0;
  383. // Check the response type
  384. const contentType = response.headers.get('content-type');
  385. if (hasRequests) {
  386. if (contentType?.includes('text/event-stream')) {
  387. // Handle SSE stream responses for requests
  388. // We use the same handler as standalone streams, which now supports
  389. // reconnection with the last event ID
  390. this._handleSseStream(response.body, { onresumptiontoken }, false);
  391. }
  392. else if (contentType?.includes('application/json')) {
  393. // For non-streaming servers, we might get direct JSON responses
  394. const data = await response.json();
  395. const responseMessages = Array.isArray(data)
  396. ? data.map(msg => JSONRPCMessageSchema.parse(msg))
  397. : [JSONRPCMessageSchema.parse(data)];
  398. for (const msg of responseMessages) {
  399. this.onmessage?.(msg);
  400. }
  401. }
  402. else {
  403. await response.body?.cancel();
  404. throw new StreamableHTTPError(-1, `Unexpected content type: ${contentType}`);
  405. }
  406. }
  407. else {
  408. // No requests in message but got 200 OK - still need to release connection
  409. await response.body?.cancel();
  410. }
  411. }
  412. catch (error) {
  413. this.onerror?.(error);
  414. throw error;
  415. }
  416. }
  417. get sessionId() {
  418. return this._sessionId;
  419. }
  420. /**
  421. * Terminates the current session by sending a DELETE request to the server.
  422. *
  423. * Clients that no longer need a particular session
  424. * (e.g., because the user is leaving the client application) SHOULD send an
  425. * HTTP DELETE to the MCP endpoint with the Mcp-Session-Id header to explicitly
  426. * terminate the session.
  427. *
  428. * The server MAY respond with HTTP 405 Method Not Allowed, indicating that
  429. * the server does not allow clients to terminate sessions.
  430. */
  431. async terminateSession() {
  432. if (!this._sessionId) {
  433. return; // No session to terminate
  434. }
  435. try {
  436. const headers = await this._commonHeaders();
  437. const init = {
  438. ...this._requestInit,
  439. method: 'DELETE',
  440. headers,
  441. signal: this._abortController?.signal
  442. };
  443. const response = await (this._fetch ?? fetch)(this._url, init);
  444. await response.body?.cancel();
  445. // We specifically handle 405 as a valid response according to the spec,
  446. // meaning the server does not support explicit session termination
  447. if (!response.ok && response.status !== 405) {
  448. throw new StreamableHTTPError(response.status, `Failed to terminate session: ${response.statusText}`);
  449. }
  450. this._sessionId = undefined;
  451. }
  452. catch (error) {
  453. this.onerror?.(error);
  454. throw error;
  455. }
  456. }
  457. setProtocolVersion(version) {
  458. this._protocolVersion = version;
  459. }
  460. get protocolVersion() {
  461. return this._protocolVersion;
  462. }
  463. /**
  464. * Resume an SSE stream from a previous event ID.
  465. * Opens a GET SSE connection with Last-Event-ID header to replay missed events.
  466. *
  467. * @param lastEventId The event ID to resume from
  468. * @param options Optional callback to receive new resumption tokens
  469. */
  470. async resumeStream(lastEventId, options) {
  471. await this._startOrAuthSse({
  472. resumptionToken: lastEventId,
  473. onresumptiontoken: options?.onresumptiontoken
  474. });
  475. }
  476. }
  477. //# sourceMappingURL=streamableHttp.js.map