Skip to content

Instantly share code, notes, and snippets.

@stuzero
Created April 19, 2025 13:38
Show Gist options
  • Save stuzero/894438450739525ace7ff85406efe77e to your computer and use it in GitHub Desktop.
Save stuzero/894438450739525ace7ff85406efe77e to your computer and use it in GitHub Desktop.
MCP Client for Static Sites
// 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