avancement planning

This commit is contained in:
2026-05-26 11:58:39 +02:00
parent 619a2b240a
commit 150b97cd2e
4892 changed files with 99214 additions and 429382 deletions
+193 -91
View File
@@ -1,6 +1,7 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.StreamableHTTPClientTransport = exports.StreamableHTTPError = void 0;
const transport_js_1 = require("../shared/transport.js");
const types_js_1 = require("../types.js");
const auth_js_1 = require("./auth.js");
const stream_1 = require("eventsource-parser/stream");
@@ -9,7 +10,7 @@ const DEFAULT_STREAMABLE_HTTP_RECONNECTION_OPTIONS = {
initialReconnectionDelay: 1000,
maxReconnectionDelay: 30000,
reconnectionDelayGrowFactor: 1.5,
maxRetries: 2,
maxRetries: 2
};
class StreamableHTTPError extends Error {
constructor(code, message) {
@@ -25,72 +26,77 @@ exports.StreamableHTTPError = StreamableHTTPError;
*/
class StreamableHTTPClientTransport {
constructor(url, opts) {
var _a;
this._hasCompletedAuthFlow = false; // Circuit breaker: detect auth success followed by immediate 401
this._url = url;
this._resourceMetadataUrl = undefined;
this._requestInit = opts === null || opts === void 0 ? void 0 : opts.requestInit;
this._authProvider = opts === null || opts === void 0 ? void 0 : opts.authProvider;
this._fetch = opts === null || opts === void 0 ? void 0 : opts.fetch;
this._sessionId = opts === null || opts === void 0 ? void 0 : opts.sessionId;
this._reconnectionOptions = (_a = opts === null || opts === void 0 ? void 0 : opts.reconnectionOptions) !== null && _a !== void 0 ? _a : DEFAULT_STREAMABLE_HTTP_RECONNECTION_OPTIONS;
this._scope = undefined;
this._requestInit = opts?.requestInit;
this._authProvider = opts?.authProvider;
this._fetch = opts?.fetch;
this._fetchWithInit = (0, transport_js_1.createFetchWithInit)(opts?.fetch, opts?.requestInit);
this._sessionId = opts?.sessionId;
this._reconnectionOptions = opts?.reconnectionOptions ?? DEFAULT_STREAMABLE_HTTP_RECONNECTION_OPTIONS;
}
async _authThenStart() {
var _a;
if (!this._authProvider) {
throw new auth_js_1.UnauthorizedError("No auth provider");
throw new auth_js_1.UnauthorizedError('No auth provider');
}
let result;
try {
result = await (0, auth_js_1.auth)(this._authProvider, { serverUrl: this._url, resourceMetadataUrl: this._resourceMetadataUrl, fetchFn: this._fetch });
result = await (0, auth_js_1.auth)(this._authProvider, {
serverUrl: this._url,
resourceMetadataUrl: this._resourceMetadataUrl,
scope: this._scope,
fetchFn: this._fetchWithInit
});
}
catch (error) {
(_a = this.onerror) === null || _a === void 0 ? void 0 : _a.call(this, error);
this.onerror?.(error);
throw error;
}
if (result !== "AUTHORIZED") {
if (result !== 'AUTHORIZED') {
throw new auth_js_1.UnauthorizedError();
}
return await this._startOrAuthSse({ resumptionToken: undefined });
}
async _commonHeaders() {
var _a;
const headers = {};
if (this._authProvider) {
const tokens = await this._authProvider.tokens();
if (tokens) {
headers["Authorization"] = `Bearer ${tokens.access_token}`;
headers['Authorization'] = `Bearer ${tokens.access_token}`;
}
}
if (this._sessionId) {
headers["mcp-session-id"] = this._sessionId;
headers['mcp-session-id'] = this._sessionId;
}
if (this._protocolVersion) {
headers["mcp-protocol-version"] = this._protocolVersion;
headers['mcp-protocol-version'] = this._protocolVersion;
}
const extraHeaders = this._normalizeHeaders((_a = this._requestInit) === null || _a === void 0 ? void 0 : _a.headers);
const extraHeaders = (0, transport_js_1.normalizeHeaders)(this._requestInit?.headers);
return new Headers({
...headers,
...extraHeaders,
...extraHeaders
});
}
async _startOrAuthSse(options) {
var _a, _b, _c;
const { resumptionToken } = options;
try {
// Try to open an initial SSE stream with GET to listen for server messages
// This is optional according to the spec - server may not support it
const headers = await this._commonHeaders();
headers.set("Accept", "text/event-stream");
headers.set('Accept', 'text/event-stream');
// Include Last-Event-ID header for resumable streams if provided
if (resumptionToken) {
headers.set("last-event-id", resumptionToken);
headers.set('last-event-id', resumptionToken);
}
const response = await ((_a = this._fetch) !== null && _a !== void 0 ? _a : fetch)(this._url, {
method: "GET",
const response = await (this._fetch ?? fetch)(this._url, {
method: 'GET',
headers,
signal: (_b = this._abortController) === null || _b === void 0 ? void 0 : _b.signal,
signal: this._abortController?.signal
});
if (!response.ok) {
await response.body?.cancel();
if (response.status === 401 && this._authProvider) {
// Need to authenticate
return await this._authThenStart();
@@ -105,7 +111,7 @@ class StreamableHTTPClientTransport {
this._handleSseStream(response.body, options, true);
}
catch (error) {
(_c = this.onerror) === null || _c === void 0 ? void 0 : _c.call(this, error);
this.onerror?.(error);
throw error;
}
}
@@ -116,47 +122,38 @@ class StreamableHTTPClientTransport {
* @returns Time to wait in milliseconds before next reconnection attempt
*/
_getNextReconnectionDelay(attempt) {
// Access default values directly, ensuring they're never undefined
// Use server-provided retry value if available
if (this._serverRetryMs !== undefined) {
return this._serverRetryMs;
}
// Fall back to exponential backoff
const initialDelay = this._reconnectionOptions.initialReconnectionDelay;
const growFactor = this._reconnectionOptions.reconnectionDelayGrowFactor;
const maxDelay = this._reconnectionOptions.maxReconnectionDelay;
// Cap at maximum delay
return Math.min(initialDelay * Math.pow(growFactor, attempt), maxDelay);
}
_normalizeHeaders(headers) {
if (!headers)
return {};
if (headers instanceof Headers) {
return Object.fromEntries(headers.entries());
}
if (Array.isArray(headers)) {
return Object.fromEntries(headers);
}
return { ...headers };
}
/**
* Schedule a reconnection attempt with exponential backoff
* Schedule a reconnection attempt using server-provided retry interval or backoff
*
* @param lastEventId The ID of the last received event for resumability
* @param attemptCount Current reconnection attempt count for this specific stream
*/
_scheduleReconnection(options, attemptCount = 0) {
var _a;
// Use provided options or default options
const maxRetries = this._reconnectionOptions.maxRetries;
// Check if we've exceeded maximum retry attempts
if (maxRetries > 0 && attemptCount >= maxRetries) {
(_a = this.onerror) === null || _a === void 0 ? void 0 : _a.call(this, new Error(`Maximum reconnection attempts (${maxRetries}) exceeded.`));
if (attemptCount >= maxRetries) {
this.onerror?.(new Error(`Maximum reconnection attempts (${maxRetries}) exceeded.`));
return;
}
// Calculate next delay based on current attempt count
const delay = this._getNextReconnectionDelay(attemptCount);
// Schedule the reconnection
setTimeout(() => {
this._reconnectionTimeout = setTimeout(() => {
// Use the last event ID to resume where we left off
this._startOrAuthSse(options).catch(error => {
var _a;
(_a = this.onerror) === null || _a === void 0 ? void 0 : _a.call(this, new Error(`Failed to reconnect SSE stream: ${error instanceof Error ? error.message : String(error)}`));
this.onerror?.(new Error(`Failed to reconnect SSE stream: ${error instanceof Error ? error.message : String(error)}`));
// Schedule another attempt if this one failed, incrementing the attempt counter
this._scheduleReconnection(options, attemptCount + 1);
});
@@ -168,15 +165,25 @@ class StreamableHTTPClientTransport {
}
const { onresumptiontoken, replayMessageId } = options;
let lastEventId;
// Track whether we've received a priming event (event with ID)
// Per spec, server SHOULD send a priming event with ID before closing
let hasPrimingEvent = false;
// Track whether we've received a response - if so, no need to reconnect
// Reconnection is for when server disconnects BEFORE sending response
let receivedResponse = false;
const processStream = async () => {
var _a, _b, _c, _d;
// this is the closest we can get to trying to catch network errors
// if something happens reader will throw
try {
// Create a pipeline: binary stream -> text decoder -> SSE parser
const reader = stream
.pipeThrough(new TextDecoderStream())
.pipeThrough(new stream_1.EventSourceParserStream())
.pipeThrough(new stream_1.EventSourceParserStream({
onRetry: (retryMs) => {
// Capture server-provided retry value for reconnection timing
this._serverRetryMs = retryMs;
}
}))
.getReader();
while (true) {
const { value: event, done } = await reader.read();
@@ -186,29 +193,54 @@ class StreamableHTTPClientTransport {
// Update last event ID if provided
if (event.id) {
lastEventId = event.id;
onresumptiontoken === null || onresumptiontoken === void 0 ? void 0 : onresumptiontoken(event.id);
// Mark that we've received a priming event - stream is now resumable
hasPrimingEvent = true;
onresumptiontoken?.(event.id);
}
if (!event.event || event.event === "message") {
// Skip events with no data (priming events, keep-alives)
if (!event.data) {
continue;
}
if (!event.event || event.event === 'message') {
try {
const message = types_js_1.JSONRPCMessageSchema.parse(JSON.parse(event.data));
if (replayMessageId !== undefined && (0, types_js_1.isJSONRPCResponse)(message)) {
message.id = replayMessageId;
if ((0, types_js_1.isJSONRPCResultResponse)(message)) {
// Mark that we received a response - no need to reconnect for this request
receivedResponse = true;
if (replayMessageId !== undefined) {
message.id = replayMessageId;
}
}
(_a = this.onmessage) === null || _a === void 0 ? void 0 : _a.call(this, message);
this.onmessage?.(message);
}
catch (error) {
(_b = this.onerror) === null || _b === void 0 ? void 0 : _b.call(this, error);
this.onerror?.(error);
}
}
}
// Handle graceful server-side disconnect
// Server may close connection after sending event ID and retry field
// Reconnect if: already reconnectable (GET stream) OR received a priming event (POST stream with event ID)
// BUT don't reconnect if we already received a response - the request is complete
const canResume = isReconnectable || hasPrimingEvent;
const needsReconnect = canResume && !receivedResponse;
if (needsReconnect && this._abortController && !this._abortController.signal.aborted) {
this._scheduleReconnection({
resumptionToken: lastEventId,
onresumptiontoken,
replayMessageId
}, 0);
}
}
catch (error) {
// Handle stream errors - likely a network disconnect
(_c = this.onerror) === null || _c === void 0 ? void 0 : _c.call(this, new Error(`SSE stream disconnected: ${error}`));
this.onerror?.(new Error(`SSE stream disconnected: ${error}`));
// Attempt to reconnect if the stream disconnects unexpectedly and we aren't closing
if (isReconnectable &&
this._abortController &&
!this._abortController.signal.aborted) {
// Reconnect if: already reconnectable (GET stream) OR received a priming event (POST stream with event ID)
// BUT don't reconnect if we already received a response - the request is complete
const canResume = isReconnectable || hasPrimingEvent;
const needsReconnect = canResume && !receivedResponse;
if (needsReconnect && this._abortController && !this._abortController.signal.aborted) {
// Use the exponential backoff reconnection strategy
try {
this._scheduleReconnection({
@@ -218,7 +250,7 @@ class StreamableHTTPClientTransport {
}, 0);
}
catch (error) {
(_d = this.onerror) === null || _d === void 0 ? void 0 : _d.call(this, new Error(`Failed to reconnect: ${error instanceof Error ? error.message : String(error)}`));
this.onerror?.(new Error(`Failed to reconnect: ${error instanceof Error ? error.message : String(error)}`));
}
}
}
@@ -227,7 +259,7 @@ class StreamableHTTPClientTransport {
}
async start() {
if (this._abortController) {
throw new Error("StreamableHTTPClientTransport already started! If using Client class, note that connect() calls start() automatically.");
throw new Error('StreamableHTTPClientTransport already started! If using Client class, note that connect() calls start() automatically.');
}
this._abortController = new AbortController();
}
@@ -236,96 +268,153 @@ class StreamableHTTPClientTransport {
*/
async finishAuth(authorizationCode) {
if (!this._authProvider) {
throw new auth_js_1.UnauthorizedError("No auth provider");
throw new auth_js_1.UnauthorizedError('No auth provider');
}
const result = await (0, auth_js_1.auth)(this._authProvider, { serverUrl: this._url, authorizationCode, resourceMetadataUrl: this._resourceMetadataUrl, fetchFn: this._fetch });
if (result !== "AUTHORIZED") {
throw new auth_js_1.UnauthorizedError("Failed to authorize");
const result = await (0, auth_js_1.auth)(this._authProvider, {
serverUrl: this._url,
authorizationCode,
resourceMetadataUrl: this._resourceMetadataUrl,
scope: this._scope,
fetchFn: this._fetchWithInit
});
if (result !== 'AUTHORIZED') {
throw new auth_js_1.UnauthorizedError('Failed to authorize');
}
}
async close() {
var _a, _b;
// Abort any pending requests
(_a = this._abortController) === null || _a === void 0 ? void 0 : _a.abort();
(_b = this.onclose) === null || _b === void 0 ? void 0 : _b.call(this);
if (this._reconnectionTimeout) {
clearTimeout(this._reconnectionTimeout);
this._reconnectionTimeout = undefined;
}
this._abortController?.abort();
this.onclose?.();
}
async send(message, options) {
var _a, _b, _c, _d;
try {
const { resumptionToken, onresumptiontoken } = options || {};
if (resumptionToken) {
// If we have at last event ID, we need to reconnect the SSE stream
this._startOrAuthSse({ resumptionToken, replayMessageId: (0, types_js_1.isJSONRPCRequest)(message) ? message.id : undefined }).catch(err => { var _a; return (_a = this.onerror) === null || _a === void 0 ? void 0 : _a.call(this, err); });
this._startOrAuthSse({ resumptionToken, replayMessageId: (0, types_js_1.isJSONRPCRequest)(message) ? message.id : undefined }).catch(err => this.onerror?.(err));
return;
}
const headers = await this._commonHeaders();
headers.set("content-type", "application/json");
headers.set("accept", "application/json, text/event-stream");
headers.set('content-type', 'application/json');
headers.set('accept', 'application/json, text/event-stream');
const init = {
...this._requestInit,
method: "POST",
method: 'POST',
headers,
body: JSON.stringify(message),
signal: (_a = this._abortController) === null || _a === void 0 ? void 0 : _a.signal,
signal: this._abortController?.signal
};
const response = await ((_b = this._fetch) !== null && _b !== void 0 ? _b : fetch)(this._url, init);
const response = await (this._fetch ?? fetch)(this._url, init);
// Handle session ID received during initialization
const sessionId = response.headers.get("mcp-session-id");
const sessionId = response.headers.get('mcp-session-id');
if (sessionId) {
this._sessionId = sessionId;
}
if (!response.ok) {
const text = await response.text().catch(() => null);
if (response.status === 401 && this._authProvider) {
this._resourceMetadataUrl = (0, auth_js_1.extractResourceMetadataUrl)(response);
const result = await (0, auth_js_1.auth)(this._authProvider, { serverUrl: this._url, resourceMetadataUrl: this._resourceMetadataUrl, fetchFn: this._fetch });
if (result !== "AUTHORIZED") {
// Prevent infinite recursion when server returns 401 after successful auth
if (this._hasCompletedAuthFlow) {
throw new StreamableHTTPError(401, 'Server returned 401 after successful authentication');
}
const { resourceMetadataUrl, scope } = (0, auth_js_1.extractWWWAuthenticateParams)(response);
this._resourceMetadataUrl = resourceMetadataUrl;
this._scope = scope;
const result = await (0, auth_js_1.auth)(this._authProvider, {
serverUrl: this._url,
resourceMetadataUrl: this._resourceMetadataUrl,
scope: this._scope,
fetchFn: this._fetchWithInit
});
if (result !== 'AUTHORIZED') {
throw new auth_js_1.UnauthorizedError();
}
// Mark that we completed auth flow
this._hasCompletedAuthFlow = true;
// Purposely _not_ awaited, so we don't call onerror twice
return this.send(message);
}
const text = await response.text().catch(() => null);
throw new Error(`Error POSTing to endpoint (HTTP ${response.status}): ${text}`);
if (response.status === 403 && this._authProvider) {
const { resourceMetadataUrl, scope, error } = (0, auth_js_1.extractWWWAuthenticateParams)(response);
if (error === 'insufficient_scope') {
const wwwAuthHeader = response.headers.get('WWW-Authenticate');
// Check if we've already tried upscoping with this header to prevent infinite loops.
if (this._lastUpscopingHeader === wwwAuthHeader) {
throw new StreamableHTTPError(403, 'Server returned 403 after trying upscoping');
}
if (scope) {
this._scope = scope;
}
if (resourceMetadataUrl) {
this._resourceMetadataUrl = resourceMetadataUrl;
}
// Mark that upscoping was tried.
this._lastUpscopingHeader = wwwAuthHeader ?? undefined;
const result = await (0, auth_js_1.auth)(this._authProvider, {
serverUrl: this._url,
resourceMetadataUrl: this._resourceMetadataUrl,
scope: this._scope,
fetchFn: this._fetch
});
if (result !== 'AUTHORIZED') {
throw new auth_js_1.UnauthorizedError();
}
return this.send(message);
}
}
throw new StreamableHTTPError(response.status, `Error POSTing to endpoint: ${text}`);
}
// Reset auth loop flag on successful response
this._hasCompletedAuthFlow = false;
this._lastUpscopingHeader = undefined;
// If the response is 202 Accepted, there's no body to process
if (response.status === 202) {
await response.body?.cancel();
// if the accepted notification is initialized, we start the SSE stream
// if it's supported by the server
if ((0, types_js_1.isInitializedNotification)(message)) {
// Start without a lastEventId since this is a fresh connection
this._startOrAuthSse({ resumptionToken: undefined }).catch(err => { var _a; return (_a = this.onerror) === null || _a === void 0 ? void 0 : _a.call(this, err); });
this._startOrAuthSse({ resumptionToken: undefined }).catch(err => this.onerror?.(err));
}
return;
}
// Get original message(s) for detecting request IDs
const messages = Array.isArray(message) ? message : [message];
const hasRequests = messages.filter(msg => "method" in msg && "id" in msg && msg.id !== undefined).length > 0;
const hasRequests = messages.filter(msg => 'method' in msg && 'id' in msg && msg.id !== undefined).length > 0;
// Check the response type
const contentType = response.headers.get("content-type");
const contentType = response.headers.get('content-type');
if (hasRequests) {
if (contentType === null || contentType === void 0 ? void 0 : contentType.includes("text/event-stream")) {
if (contentType?.includes('text/event-stream')) {
// Handle SSE stream responses for requests
// We use the same handler as standalone streams, which now supports
// reconnection with the last event ID
this._handleSseStream(response.body, { onresumptiontoken }, false);
}
else if (contentType === null || contentType === void 0 ? void 0 : contentType.includes("application/json")) {
else if (contentType?.includes('application/json')) {
// For non-streaming servers, we might get direct JSON responses
const data = await response.json();
const responseMessages = Array.isArray(data)
? data.map(msg => types_js_1.JSONRPCMessageSchema.parse(msg))
: [types_js_1.JSONRPCMessageSchema.parse(data)];
for (const msg of responseMessages) {
(_c = this.onmessage) === null || _c === void 0 ? void 0 : _c.call(this, msg);
this.onmessage?.(msg);
}
}
else {
await response.body?.cancel();
throw new StreamableHTTPError(-1, `Unexpected content type: ${contentType}`);
}
}
else {
// No requests in message but got 200 OK - still need to release connection
await response.body?.cancel();
}
}
catch (error) {
(_d = this.onerror) === null || _d === void 0 ? void 0 : _d.call(this, error);
this.onerror?.(error);
throw error;
}
}
@@ -344,7 +433,6 @@ class StreamableHTTPClientTransport {
* the server does not allow clients to terminate sessions.
*/
async terminateSession() {
var _a, _b, _c;
if (!this._sessionId) {
return; // No session to terminate
}
@@ -352,11 +440,12 @@ class StreamableHTTPClientTransport {
const headers = await this._commonHeaders();
const init = {
...this._requestInit,
method: "DELETE",
method: 'DELETE',
headers,
signal: (_a = this._abortController) === null || _a === void 0 ? void 0 : _a.signal,
signal: this._abortController?.signal
};
const response = await ((_b = this._fetch) !== null && _b !== void 0 ? _b : fetch)(this._url, init);
const response = await (this._fetch ?? fetch)(this._url, init);
await response.body?.cancel();
// We specifically handle 405 as a valid response according to the spec,
// meaning the server does not support explicit session termination
if (!response.ok && response.status !== 405) {
@@ -365,7 +454,7 @@ class StreamableHTTPClientTransport {
this._sessionId = undefined;
}
catch (error) {
(_c = this.onerror) === null || _c === void 0 ? void 0 : _c.call(this, error);
this.onerror?.(error);
throw error;
}
}
@@ -375,6 +464,19 @@ class StreamableHTTPClientTransport {
get protocolVersion() {
return this._protocolVersion;
}
/**
* Resume an SSE stream from a previous event ID.
* Opens a GET SSE connection with Last-Event-ID header to replay missed events.
*
* @param lastEventId The event ID to resume from
* @param options Optional callback to receive new resumption tokens
*/
async resumeStream(lastEventId, options) {
await this._startOrAuthSse({
resumptionToken: lastEventId,
onresumptiontoken: options?.onresumptiontoken
});
}
}
exports.StreamableHTTPClientTransport = StreamableHTTPClientTransport;
//# sourceMappingURL=streamableHttp.js.map