diff --git a/src/cli.ts b/src/cli.ts index 96764803f..b550508e1 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -91,7 +91,10 @@ async function runServer(port: number | null) { console.log('Received message'); const sessionId = req.query.sessionId as string; - const transport = servers.map(s => s.transport as SSEServerTransport).find(t => t.sessionId === sessionId); + + const transport = servers + .reduce((pre, val) => pre.concat(Array.from(val.transportMap.values()) as SSEServerTransport[]), [] as SSEServerTransport[]) + .find(t => t.sessionId === sessionId); if (!transport) { res.status(404).send('Session not found'); return; diff --git a/src/client/index.ts b/src/client/index.ts index 856eb18e5..4b654dc11 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -103,7 +103,7 @@ export class Client< * The new capabilities will be merged with any existing capabilities previously given (e.g., at initialization). */ public registerCapabilities(capabilities: ClientCapabilities): void { - if (this.transport) { + if (this.transportMap.size > 0) { throw new Error('Cannot register capabilities after connecting to transport'); } diff --git a/src/inMemory.ts b/src/inMemory.ts index 26062624d..7a1e11d60 100644 --- a/src/inMemory.ts +++ b/src/inMemory.ts @@ -18,6 +18,8 @@ export class InMemoryTransport implements Transport { onerror?: (error: Error) => void; onmessage?: (message: JSONRPCMessage, extra?: { authInfo?: AuthInfo }) => void; sessionId?: string; + onsessionclosed?: ((sessionId: string) => void | Promise) | undefined; + onsessioninitialized?: ((sessionId: string) => void | Promise) | undefined; /** * Creates a pair of linked in-memory transports that can communicate with each other. One should be passed to a Client and one to a Server. @@ -31,6 +33,7 @@ export class InMemoryTransport implements Transport { } async start(): Promise { + this.onsessioninitialized?.(this.sessionId!); // Process any messages that were queued before start was called while (this._messageQueue.length > 0) { const queuedMessage = this._messageQueue.shift()!; @@ -39,6 +42,7 @@ export class InMemoryTransport implements Transport { } async close(): Promise { + this.onsessionclosed?.(this.sessionId!); const other = this._otherTransport; this._otherTransport = undefined; await other?.close(); diff --git a/src/server/index.ts b/src/server/index.ts index 3eb0ba0d4..216b97ad4 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -131,7 +131,7 @@ export class Server< * The new capabilities will be merged with any existing capabilities previously given (e.g., at initialization). */ public registerCapabilities(capabilities: ServerCapabilities): void { - if (this.transport) { + if (this.transportMap.size > 0) { throw new Error('Cannot register capabilities after connecting to transport'); } this._capabilities = mergeCapabilities(this._capabilities, capabilities); @@ -277,8 +277,8 @@ export class Server< return this._capabilities; } - async ping() { - return this.request({ method: 'ping' }, EmptyResultSchema); + async ping(sessionId?: string) { + return this.request({ method: 'ping' }, EmptyResultSchema, { sessionId }); } async createMessage(params: CreateMessageRequest['params'], options?: RequestOptions) { @@ -327,7 +327,7 @@ export class Server< async sendLoggingMessage(params: LoggingMessageNotification['params'], sessionId?: string) { if (this._capabilities.logging) { if (!this.isMessageIgnored(params.level, sessionId)) { - return this.notification({ method: 'notifications/message', params }); + return this.notification({ method: 'notifications/message', params }, { sessionId }); } } } diff --git a/src/server/mcp.ts b/src/server/mcp.ts index cef1722d6..813339de2 100644 --- a/src/server/mcp.ts +++ b/src/server/mcp.ts @@ -902,7 +902,7 @@ export class McpServer { * @returns True if the server is connected */ isConnected() { - return this.server.transport !== undefined; + return this.server.transportMap.size > 0; } /** diff --git a/src/server/multipleLinks.test.ts b/src/server/multipleLinks.test.ts new file mode 100644 index 000000000..3a46872d3 --- /dev/null +++ b/src/server/multipleLinks.test.ts @@ -0,0 +1,398 @@ +import { Client } from '../client/index.js'; +import { SSEClientTransport } from '../client/sse.js'; +import { StreamableHTTPClientTransport } from '../client/streamableHttp.js'; +import express, { Request, Response } from 'express'; +import { McpServer } from '../server/mcp.js'; +import { SSEServerTransport } from '../server/sse.js'; +import { InMemoryEventStore } from '../examples/shared/inMemoryEventStore.js'; +import { StreamableHTTPServerTransport } from '../server/streamableHttp.js'; +import { z } from 'zod'; +import { randomUUID } from 'node:crypto'; +import * as http from 'http'; +import cors from 'cors'; +import { isInitializeRequest } from '../types.js'; + +const server = new McpServer( + { + name: 'simple-sse-server', + version: '1.0.0' + }, + { capabilities: { logging: {} } } +); + +server.registerTool( + 'sayHello', + { + description: 'Says hello to the user', + inputSchema: { + name: z.string().describe('Name to include in greeting') + } + }, + async ({ name }) => { + return { + content: [{ type: 'text', text: 'Hello, ' + name + '!' }] + }; + } +); + +const transports: Record = {}; + +let expressServer: http.Server; + +describe.only('multipleLinks.test.js', () => { + beforeAll(async () => { + const app = express(); + app.use(express.json()); + + app.use( + cors({ + origin: '*', // Allow all origins - adjust as needed for production + exposedHeaders: ['Mcp-Session-Id'] + }) + ); + + app.get('/sse', async (req: Request, res: Response) => { + console.log('Received GET request to /sse (establishing SSE stream)'); + + try { + // Create a new SSE transport for the client + // The endpoint for POST messages is '/messages' + const transport = new SSEServerTransport('/messages', res); + + // Store the transport by session ID + const sessionId = transport.sessionId; + transports[sessionId] = transport; + + // Set up onclose handler to clean up transport when closed + transport.onclose = () => { + delete transports[sessionId]; + }; + + // Connect the transport to the MCP server + await server.connect(transport); + + console.log(`Established SSE stream with session ID: ${sessionId}`); + } catch (error) { + console.error('Error establishing SSE stream:', error); + if (!res.headersSent) { + res.status(500).send('Error establishing SSE stream'); + } + } + }); + + app.post('/messages', async (req: Request, res: Response) => { + console.log('Received POST request to /messages'); + + // Extract session ID from URL query parameter + // In the SSE protocol, this is added by the client based on the endpoint event + const sessionId = req.query.sessionId as string | undefined; + + console.log('Session ID:', sessionId); + + if (!sessionId) { + console.error('No session ID provided in request URL'); + res.status(400).send('Missing sessionId parameter'); + return; + } + + const transport = transports[sessionId] as SSEServerTransport; + if (!transport) { + console.error(`No active transport found for session ID: ${sessionId}`); + res.status(404).send('Session not found'); + return; + } + + try { + // Handle the POST message with the transport + await transport.handlePostMessage(req, res, req.body); + } catch (error) { + console.error('Error handling request:', error); + if (!res.headersSent) { + res.status(500).send('Error handling request'); + } + } + }); + + app.all('/mcp', async (req: Request, res: Response) => { + console.log(`Received ${req.method} request to /mcp`); + + try { + // Check for existing session ID + const sessionId = req.headers['mcp-session-id'] as string | undefined; + let transport: StreamableHTTPServerTransport; + + if (sessionId && transports[sessionId]) { + // Check if the transport is of the correct type + const existingTransport = transports[sessionId]; + if (existingTransport instanceof StreamableHTTPServerTransport) { + // Reuse existing transport + transport = existingTransport; + } else { + // Transport exists but is not a StreamableHTTPServerTransport (could be SSEServerTransport) + res.status(400).json({ + jsonrpc: '2.0', + error: { + code: -32000, + message: 'Bad Request: Session exists but uses a different transport protocol' + }, + id: null + }); + return; + } + } else if (!sessionId && req.method === 'POST' && isInitializeRequest(req.body)) { + const eventStore = new InMemoryEventStore(); + transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID(), + eventStore, // Enable resumability + onsessioninitialized: sessionId => { + // Store the transport by session ID when session is initialized + console.log(`StreamableHTTP session initialized with ID: ${sessionId}`); + transports[sessionId] = transport; + } + }); + + // Set up onclose handler to clean up transport when closed + transport.onclose = () => { + const sid = transport.sessionId; + if (sid && transports[sid]) { + console.log(`Transport closed for session ${sid}, removing from transports map`); + delete transports[sid]; + } + }; + + // Connect the transport to the MCP server + await server.connect(transport); + } else { + // Invalid request - no session ID or not initialization request + res.status(400).json({ + jsonrpc: '2.0', + error: { + code: -32000, + message: 'Bad Request: No valid session ID provided' + }, + id: null + }); + return; + } + + // Handle the request with the transport + await transport.handleRequest(req, res, req.body); + } catch (error) { + console.error('Error handling MCP request:', error); + if (!res.headersSent) { + res.status(500).json({ + jsonrpc: '2.0', + error: { + code: -32603, + message: 'Internal server error' + }, + id: null + }); + } + } + }); + + app.post('/stateless/mcp', async (req: Request, res: Response) => { + try { + const transport: StreamableHTTPServerTransport = new StreamableHTTPServerTransport({ + sessionIdGenerator: undefined + }); + await server.connect(transport); + await transport.handleRequest(req, res, req.body); + res.on('close', () => { + console.log('Request closed'); + transport.close(); + server.close(); + }); + } catch (error) { + console.error('Error handling MCP request:', error); + if (!res.headersSent) { + res.status(500).json({ + jsonrpc: '2.0', + error: { + code: -32603, + message: 'Internal server error' + }, + id: null + }); + } + } + }); + + app.get('/stateless/mcp', async (req: Request, res: Response) => { + console.log('Received GET MCP request'); + res.writeHead(405).end( + JSON.stringify({ + jsonrpc: '2.0', + error: { + code: -32000, + message: 'Method not allowed.' + }, + id: null + }) + ); + }); + + app.delete('/stateless/mcp', async (req: Request, res: Response) => { + console.log('Received DELETE MCP request'); + res.writeHead(405).end( + JSON.stringify({ + jsonrpc: '2.0', + error: { + code: -32000, + message: 'Method not allowed.' + }, + id: null + }) + ); + }); + + let serverResolve: () => void; + + const serverReady = new Promise(resolve => { + serverResolve = resolve; + }); + + // Start the server + const PORT = 3002; + expressServer = app.listen(PORT, error => { + if (error) { + console.error('Failed to start server:', error); + process.exit(1); + } + console.log(`Simple SSE Server (deprecated protocol version 2024-11-05) listening on port ${PORT}`); + serverResolve(); + }); + await serverReady; + }); + + afterAll(async () => { + console.log('Shutting down server...'); + + // Close all active transports to properly clean up resources + for (const sessionId in transports) { + try { + console.log(`Closing transport for session ${sessionId}`); + await transports[sessionId].close(); + delete transports[sessionId]; + } catch (error) { + console.error(`Error closing transport for session ${sessionId}:`, error); + } + } + await server.close(); + await expressServer.close(); + + console.log('Server shutdown complete'); + }); + + it('should run multiple sse links', async () => { + const firstClient = new Client({ + name: 'test-first-client', + version: '1.0.0' + }); + + const firstTransport = new SSEClientTransport(new URL('http://localhost:3002/sse')); + + await firstClient.connect(firstTransport); + + const secondClient = new Client({ + name: 'test-second-client', + version: '1.0.0' + }); + + const secondTransport = new SSEClientTransport(new URL('http://localhost:3002/sse')); + + await secondClient.connect(secondTransport); + + await firstClient.callTool({ + name: 'sayHello', + arguments: { + name: 'John' + } + }); + + await secondClient.callTool({ + name: 'sayHello', + arguments: { + name: 'John' + } + }); + + await firstClient.close(); + await secondClient.close(); + }); + + it('should run multiple streamable links', async () => { + const firstClient = new Client({ + name: 'test-first-client', + version: '1.0.0' + }); + + const firstTransport = new StreamableHTTPClientTransport(new URL('http://localhost:3002/mcp')); + + await firstClient.connect(firstTransport); + + const secondClient = new Client({ + name: 'test-second-client', + version: '1.0.0' + }); + + const secondTransport = new StreamableHTTPClientTransport(new URL('http://localhost:3002/mcp')); + + await secondClient.connect(secondTransport); + + await firstClient.callTool({ + name: 'sayHello', + arguments: { + name: 'John' + } + }); + + await secondClient.callTool({ + name: 'sayHello', + arguments: { + name: 'John' + } + }); + + await firstClient.close(); + await secondClient.close(); + }); + + it('should run multiple stateless streamable links', async () => { + const firstClient = new Client({ + name: 'test-first-client', + version: '1.0.0' + }); + + const firstTransport = new StreamableHTTPClientTransport(new URL('http://localhost:3002/stateless/mcp')); + + await firstClient.connect(firstTransport); + + const secondClient = new Client({ + name: 'test-second-client', + version: '1.0.0' + }); + + const secondTransport = new StreamableHTTPClientTransport(new URL('http://localhost:3002/stateless/mcp')); + + await secondClient.connect(secondTransport); + + await firstClient.callTool({ + name: 'sayHello', + arguments: { + name: 'John' + } + }); + + await secondClient.callTool({ + name: 'sayHello', + arguments: { + name: 'John' + } + }); + + await firstClient.close(); + await secondClient.close(); + }); +}); diff --git a/src/server/sse.ts b/src/server/sse.ts index 3405af705..35c3b151b 100644 --- a/src/server/sse.ts +++ b/src/server/sse.ts @@ -30,6 +30,16 @@ export interface SSEServerTransportOptions { * Default is false for backwards compatibility. */ enableDnsRebindingProtection?: boolean; + + /** + * Callback for when a session is initialized. + */ + onsessioninitialized?: (sessionId: string) => void; + + /** + * Callback for when a session is closed. + */ + onsessionclosed?: (sessionId: string) => void; } /** @@ -44,6 +54,8 @@ export class SSEServerTransport implements Transport { onclose?: () => void; onerror?: (error: Error) => void; onmessage?: (message: JSONRPCMessage, extra?: MessageExtraInfo) => void; + onsessioninitialized?: (sessionId: string) => void | Promise; + onsessionclosed?: (sessionId: string) => void | Promise; /** * Creates a new SSE server transport, which will direct the client to POST messages to the relative or absolute URL identified by `_endpoint`. @@ -55,6 +67,8 @@ export class SSEServerTransport implements Transport { ) { this._sessionId = randomUUID(); this._options = options || { enableDnsRebindingProtection: false }; + this.onsessioninitialized = options?.onsessioninitialized; + this.onsessionclosed = options?.onsessionclosed; } /** @@ -95,6 +109,7 @@ export class SSEServerTransport implements Transport { if (this._sseResponse) { throw new Error('SSEServerTransport already started! If using Server class, note that connect() calls start() automatically.'); } + await this.onsessioninitialized?.(this._sessionId); this.res.writeHead(200, { 'Content-Type': 'text/event-stream', @@ -191,6 +206,7 @@ export class SSEServerTransport implements Transport { async close(): Promise { this._sseResponse?.end(); this._sseResponse = undefined; + await this.onsessioninitialized?.(this._sessionId); this.onclose?.(); } diff --git a/src/server/streamableHttp.ts b/src/server/streamableHttp.ts index d57e75cd7..131ee2782 100644 --- a/src/server/streamableHttp.ts +++ b/src/server/streamableHttp.ts @@ -155,8 +155,6 @@ export class StreamableHTTPServerTransport implements Transport { private _enableJsonResponse: boolean = false; private _standaloneSseStreamId: string = '_GET_stream'; private _eventStore?: EventStore; - private _onsessioninitialized?: (sessionId: string) => void | Promise; - private _onsessionclosed?: (sessionId: string) => void | Promise; private _allowedHosts?: string[]; private _allowedOrigins?: string[]; private _enableDnsRebindingProtection: boolean; @@ -165,13 +163,15 @@ export class StreamableHTTPServerTransport implements Transport { onclose?: () => void; onerror?: (error: Error) => void; onmessage?: (message: JSONRPCMessage, extra?: MessageExtraInfo) => void; + onsessioninitialized?: (sessionId: string) => void | Promise; + onsessionclosed?: (sessionId: string) => void | Promise; constructor(options: StreamableHTTPServerTransportOptions) { this.sessionIdGenerator = options.sessionIdGenerator; this._enableJsonResponse = options.enableJsonResponse ?? false; this._eventStore = options.eventStore; - this._onsessioninitialized = options.onsessioninitialized; - this._onsessionclosed = options.onsessionclosed; + this.onsessioninitialized = options.onsessioninitialized; + this.onsessionclosed = options.onsessionclosed; this._allowedHosts = options.allowedHosts; this._allowedOrigins = options.allowedOrigins; this._enableDnsRebindingProtection = options.enableDnsRebindingProtection ?? false; @@ -502,8 +502,11 @@ export class StreamableHTTPServerTransport implements Transport { // If we have a session ID and an onsessioninitialized handler, call it immediately // This is needed in cases where the server needs to keep track of multiple sessions - if (this.sessionId && this._onsessioninitialized) { - await Promise.resolve(this._onsessioninitialized(this.sessionId)); + if (this.sessionId && this.onsessioninitialized) { + await Promise.resolve(this.onsessioninitialized(this.sessionId)); + for (const message of messages) { + this.onmessage?.(message, { authInfo, requestInfo }); + } } } if (!isInitializationRequest) { @@ -600,7 +603,7 @@ export class StreamableHTTPServerTransport implements Transport { if (!this.validateProtocolVersion(req, res)) { return; } - await Promise.resolve(this._onsessionclosed?.(this.sessionId!)); + await Promise.resolve(this.onsessionclosed?.(this.sessionId!)); await this.close(); res.writeHead(200).end(); } diff --git a/src/shared/protocol.ts b/src/shared/protocol.ts index 5cb969418..934bcf402 100644 --- a/src/shared/protocol.ts +++ b/src/shared/protocol.ts @@ -93,6 +93,11 @@ export type RequestOptions = { * If not specified, there is no maximum total timeout. */ maxTotalTimeout?: number; + + /** + * Specify the transport sent for the session ID from the transport, if available. + */ + sessionId?: string; } & TransportSendOptions; /** @@ -103,6 +108,11 @@ export type NotificationOptions = { * May be used to indicate to the transport which incoming request to associate this outgoing notification with. */ relatedRequestId?: RequestId; + + /** + * Session ID from the transport, if available. + */ + sessionId?: string; }; /** @@ -167,12 +177,13 @@ type TimeoutInfo = { onTimeout: () => void; }; +const DEFAULT_TRANSPROT_KEY = '__default_transport__'; + /** * Implements MCP protocol framing on top of a pluggable transport, including * features like request/response linking, notifications, and progress. */ export abstract class Protocol { - private _transport?: Transport; private _requestMessageId = 0; private _requestHandlers: Map< string, @@ -184,7 +195,8 @@ export abstract class Protocol = new Map(); private _timeoutInfo: Map = new Map(); private _pendingDebouncedNotifications = new Set(); - + // client only has one transport, server will have multiple + private _transportMap: Map = new Map(); /** * Callback for when the connection is closed for any reason. * @@ -275,26 +287,45 @@ export abstract class Protocol { - this._transport = transport; - const _onclose = this.transport?.onclose; - this._transport.onclose = () => { + const _onclose = transport.onclose; + + const originalOnSessionInitialized = transport.onsessioninitialized; + transport.onsessioninitialized = async (sessionId: string) => { + await originalOnSessionInitialized?.(sessionId); + if (sessionId) { + this._transportMap.set(sessionId, transport); + } + }; + + const originalOnSessionClosed = transport.onsessionclosed; + transport.onsessionclosed = async (sessionId: string) => { + await originalOnSessionClosed?.(sessionId); + if (sessionId) { + this._transportMap.delete(sessionId); + } + }; + + // client init has only one transport, not invoke onsessioninitialized + this._transportMap.set(DEFAULT_TRANSPROT_KEY, transport); + + transport.onclose = () => { _onclose?.(); - this._onclose(); + this._onclose(transport.sessionId); }; - const _onerror = this.transport?.onerror; - this._transport.onerror = (error: Error) => { + const _onerror = transport.onerror; + transport.onerror = (error: Error) => { _onerror?.(error); this._onerror(error); }; - const _onmessage = this._transport?.onmessage; - this._transport.onmessage = (message, extra) => { + const _onmessage = transport?.onmessage; + transport.onmessage = (message, extra) => { _onmessage?.(message, extra); if (isJSONRPCResponse(message) || isJSONRPCError(message)) { this._onresponse(message); } else if (isJSONRPCRequest(message)) { - this._onrequest(message, extra); + this._onrequest(message, extra, transport.sessionId); } else if (isJSONRPCNotification(message)) { this._onnotification(message); } else { @@ -302,15 +333,19 @@ export abstract class Protocol this._onerror(new Error(`Uncaught error in notification handler: ${error}`))); } - private _onrequest(request: JSONRPCRequest, extra?: MessageExtraInfo): void { - const handler = this._requestHandlers.get(request.method) ?? this.fallbackRequestHandler; + get transportMap() { + return this._transportMap; + } + private _onrequest(request: JSONRPCRequest, extra?: MessageExtraInfo, sessionId?: string): void { + const handler = this._requestHandlers.get(request.method) ?? this.fallbackRequestHandler; + let transport: Transport; + if (sessionId && this._transportMap.has(sessionId)) { + transport = this._transportMap.get(sessionId)!; + } else { + transport = this._transportMap.get(DEFAULT_TRANSPROT_KEY)!; + } // Capture the current transport at request time to ensure responses go to the correct client - const capturedTransport = this._transport; + const capturedTransport = transport; if (handler === undefined) { capturedTransport @@ -364,8 +408,9 @@ export abstract class Protocol this.notification(notification, { relatedRequestId: request.id }), - sendRequest: (r, resultSchema, options?) => this.request(r, resultSchema, { ...options, relatedRequestId: request.id }), + sendNotification: notification => this.notification(notification, { relatedRequestId: request.id, sessionId }), + sendRequest: (r, resultSchema, options?) => + this.request(r, resultSchema, { ...options, relatedRequestId: request.id, sessionId }), authInfo: extra?.authInfo, requestId: request.id, requestInfo: extra?.requestInfo @@ -452,15 +497,13 @@ export abstract class Protocol { - await this._transport?.close(); + for (const transport of this._transportMap.values()) { + await transport.close(); + } } /** @@ -490,10 +533,16 @@ export abstract class Protocol>(request: SendRequestT, resultSchema: T, options?: RequestOptions): Promise> { - const { relatedRequestId, resumptionToken, onresumptiontoken } = options ?? {}; + const { relatedRequestId, resumptionToken, onresumptiontoken, sessionId } = options ?? {}; + + let transport = this._transportMap.get(DEFAULT_TRANSPROT_KEY); + + if (sessionId) { + transport = this._transportMap.get(sessionId); + } return new Promise((resolve, reject) => { - if (!this._transport) { + if (!transport) { reject(new Error('Not connected')); return; } @@ -527,7 +576,7 @@ export abstract class Protocol { + transport.send(jsonrpcRequest, { relatedRequestId, resumptionToken, onresumptiontoken }).catch(error => { this._cleanupTimeout(messageId); reject(error); }); @@ -581,7 +630,13 @@ export abstract class Protocol { - if (!this._transport) { + let transports: Transport[] = Array.from(this._transportMap.values()); + + if (options?.sessionId && this._transportMap.has(options.sessionId)) { + transports = [this._transportMap.get(options.sessionId)!]; + } + + if (transports.length === 0) { throw new Error('Not connected'); } @@ -606,9 +661,14 @@ export abstract class Protocol { // Un-mark the notification so the next one can be scheduled. this._pendingDebouncedNotifications.delete(notification.method); + let transports: Transport[] = Array.from(this._transportMap.values()); + + if (options?.sessionId && this._transportMap.has(options.sessionId)) { + transports = [this._transportMap.get(options.sessionId)!]; + } // SAFETY CHECK: If the connection was closed while this was pending, abort. - if (!this._transport) { + if (transports.length === 0) { return; } @@ -618,7 +678,9 @@ export abstract class Protocol this._onerror(error)); + for (const transport of transports) { + transport?.send(jsonrpcNotification, options).catch(error => this._onerror(error)); + } }); // Return immediately. @@ -630,7 +692,9 @@ export abstract class Protocol void; + + /** + * Callback for when a session is initialized. + */ + onsessioninitialized?: (sessionId: string) => void | Promise; + + /** + * Callback for when a session is closed. + */ + onsessionclosed?: (sessionId: string) => void | Promise; }