Created
April 19, 2025 13:38
-
-
Save stuzero/894438450739525ace7ff85406efe77e to your computer and use it in GitHub Desktop.
MCP Client for Static Sites
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// mcp-client.js | |
export class MCPClient { | |
constructor({ eventUrl, rpcUrl }) { | |
this.rpcUrl = rpcUrl; | |
this.pending = new Map(); | |
this.sessionId = null; | |
this.endpoint = null; | |
this.initialized = false; | |
this.initId = null; | |
console.log(`Connecting to SSE endpoint: ${eventUrl}`); | |
this.eventSource = new EventSource(eventUrl); | |
// Handle the endpoint event first, before sending any requests | |
this.eventSource.addEventListener('endpoint', (event) => { | |
console.log('Received endpoint event:', event.data); | |
try { | |
// Get the absolute endpoint URL using the MCP server origin | |
const mcpBaseUrl = new URL(this.rpcUrl).origin; | |
this.endpoint = new URL(event.data, mcpBaseUrl).toString(); | |
// Extract session ID from endpoint URL | |
const url = new URL(this.endpoint); | |
this.sessionId = url.searchParams.get('session_id'); | |
console.log(`Session ID: ${this.sessionId}`); | |
console.log(`Full endpoint URL: ${this.endpoint}`); | |
// Automatically initialize | |
this._initializeSession(); | |
} catch (error) { | |
console.error('Failed to parse endpoint URL:', error); | |
} | |
}); | |
// Handle message events | |
this.eventSource.onmessage = (event) => { | |
try { | |
console.log('Received message:', event.data); | |
const message = JSON.parse(event.data); | |
this._handleMessage(message); | |
} catch (error) { | |
console.error('Error processing message:', error); | |
} | |
}; | |
this.eventSource.onopen = () => { | |
console.log('EventSource connection opened'); | |
}; | |
this.eventSource.onerror = (error) => { | |
console.error('EventSource error:', error); | |
}; | |
} | |
_handleMessage(message) { | |
console.log('Processing message:', message); | |
if (message.id && this.pending.has(message.id)) { | |
const { resolve, reject } = this.pending.get(message.id); | |
this.pending.delete(message.id); | |
if ('result' in message) { | |
// If this is the initialization result, mark as initialized and send initialized notification | |
if (message.id === this.initId) { | |
console.log('Initialization response received:', message.result); | |
// Send the initialized notification before resolving the promise | |
this._sendInitializedNotification().then(() => { | |
this.initialized = true; | |
console.log('Session fully initialized'); | |
resolve(message.result); | |
}).catch(error => { | |
console.error('Error sending initialized notification:', error); | |
reject(error); | |
}); | |
} else { | |
resolve(message.result); | |
} | |
} else if ('error' in message) { | |
reject(message.error); | |
} | |
} else { | |
console.log('Received message without matching pending request:', message); | |
} | |
} | |
async _sendInitializedNotification() { | |
console.log('Sending initialized notification'); | |
// Use the correct notification format | |
const notification = { | |
jsonrpc: "2.0", | |
method: "notifications/initialized", | |
params: {} | |
}; | |
try { | |
const response = await fetch(this.endpoint, { | |
method: 'POST', | |
headers: { 'Content-Type': 'application/json' }, | |
body: JSON.stringify(notification) | |
}); | |
if (!response.ok) { | |
const errorText = await response.text(); | |
console.error(`HTTP error sending initialized notification: ${response.status} ${response.statusText}`, errorText); | |
throw new Error(`HTTP error: ${response.status} ${response.statusText}`); | |
} | |
console.log('Initialized notification sent successfully'); | |
return true; | |
} catch (error) { | |
console.error('Error sending initialized notification:', error); | |
throw error; | |
} | |
} | |
async _initializeSession() { | |
if (!this.sessionId || !this.endpoint) { | |
throw new Error('Cannot initialize: Missing session ID or endpoint'); | |
} | |
// Store the initialization request ID | |
this.initId = crypto.randomUUID(); | |
console.log('Generated init ID:', this.initId); | |
try { | |
console.log('Sending initialization request'); | |
// This is the exact format required by MCP for initialization | |
const payload = { | |
jsonrpc: "2.0", | |
method: "initialize", | |
params: { | |
protocolVersion: "2022-09-24", | |
capabilities: { | |
tools: {}, | |
resources: {}, | |
prompts: {} | |
}, | |
clientInfo: { | |
name: "MinimalMCPClient", | |
version: "1.0.0" | |
} | |
}, | |
id: this.initId | |
}; | |
// Save the initialization promise | |
const initPromise = new Promise((resolve, reject) => { | |
this.pending.set(this.initId, { resolve, reject }); | |
// Add timeout specific to initialization | |
setTimeout(() => { | |
if (this.pending.has(this.initId)) { | |
this.pending.delete(this.initId); | |
reject(new Error(`Initialization timed out`)); | |
} | |
}, 10000); | |
}); | |
// Send initialization request | |
const response = await fetch(this.endpoint, { | |
method: 'POST', | |
headers: { 'Content-Type': 'application/json' }, | |
body: JSON.stringify(payload) | |
}); | |
console.log(`Initialization request sent, status: ${response.status}`); | |
if (!response.ok) { | |
const errorText = await response.text(); | |
console.error(`HTTP error: ${response.status} ${response.statusText}`, errorText); | |
throw new Error(`HTTP error: ${response.status} ${response.statusText}`); | |
} | |
// Wait for the initialization to complete via SSE response and initialized notification | |
return initPromise; | |
} catch (error) { | |
console.error('Error during initialization:', error); | |
throw error; | |
} | |
} | |
async call(method, params = {}) { | |
// Wait for initialization to complete | |
if (!this.initialized) { | |
await this._waitForInitialization(); | |
} | |
const id = crypto.randomUUID(); | |
const payload = { | |
jsonrpc: "2.0", | |
method, | |
params, | |
id | |
}; | |
const responsePromise = new Promise((resolve, reject) => { | |
this.pending.set(id, { resolve, reject }); | |
// Add timeout | |
setTimeout(() => { | |
if (this.pending.has(id)) { | |
this.pending.delete(id); | |
reject(new Error(`Request timed out: ${method}`)); | |
} | |
}, 30000); | |
}); | |
// Send the request to the server's endpoint | |
console.log(`Sending ${method} request to ${this.endpoint}`); | |
try { | |
const response = await fetch(this.endpoint, { | |
method: 'POST', | |
headers: { 'Content-Type': 'application/json' }, | |
body: JSON.stringify(payload) | |
}); | |
if (!response.ok) { | |
const errorText = await response.text(); | |
console.error(`HTTP error: ${response.status} ${response.statusText}`, errorText); | |
throw new Error(`HTTP error: ${response.status} ${response.statusText}`); | |
} | |
} catch (error) { | |
console.error('Error sending request:', error); | |
this.pending.delete(id); | |
throw error; | |
} | |
return responsePromise; | |
} | |
async _waitForInitialization() { | |
console.log("Waiting for initialization..."); | |
if (this.initialized) { | |
console.log("Already initialized"); | |
return; | |
} | |
if (!this.endpoint || !this.sessionId) { | |
console.log("No endpoint or session ID yet, waiting..."); | |
return new Promise((resolve, reject) => { | |
const checkInterval = setInterval(async () => { | |
if (this.endpoint && this.sessionId) { | |
clearInterval(checkInterval); | |
try { | |
await this._initializeSession(); | |
resolve(); | |
} catch (error) { | |
reject(error); | |
} | |
} | |
}, 100); | |
// Set timeout | |
setTimeout(() => { | |
clearInterval(checkInterval); | |
reject(new Error('Timed out waiting for endpoint connection')); | |
}, 5000); | |
}); | |
} else { | |
// We have the endpoint but aren't initialized yet | |
return this._initializeSession(); | |
} | |
} | |
disconnect() { | |
if (this.eventSource) { | |
this.eventSource.close(); | |
} | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment