streamableHttp.js 22 KB

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