fixing jsonSchema validation by using zod
This commit is contained in:
240
src/session/manager.ts
Normal file
240
src/session/manager.ts
Normal file
@@ -0,0 +1,240 @@
|
||||
import { storage } from './storage.js';
|
||||
import { logger } from '../utils/logger.js';
|
||||
import { SessionError } from '../utils/errors.js';
|
||||
|
||||
/**
|
||||
* Session lifecycle manager with TTL management
|
||||
* Default TTL: 1 hour (3600 seconds)
|
||||
*/
|
||||
|
||||
const DEFAULT_SESSION_TTL = parseInt(process.env.MCP_SESSION_TIMEOUT || '3600', 10);
|
||||
|
||||
/**
|
||||
* Session Manager class
|
||||
*/
|
||||
export class SessionManager {
|
||||
defaultTTL: number;
|
||||
|
||||
constructor() {
|
||||
this.defaultTTL = DEFAULT_SESSION_TTL;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new session
|
||||
* @returns {Promise<Object>} Session object with id and metadata
|
||||
*/
|
||||
async createSession() {
|
||||
const sessionId = this.generateSessionId();
|
||||
const now = Date.now();
|
||||
const expiresAt = now + (this.defaultTTL * 1000);
|
||||
|
||||
const sessionData = {
|
||||
id: sessionId,
|
||||
createdAt: now.toString(),
|
||||
expiresAt: expiresAt.toString(),
|
||||
lastActivity: now.toString(),
|
||||
bookingCount: '0',
|
||||
searchCount: '0'
|
||||
};
|
||||
|
||||
const sessionKey = this.getSessionKey(sessionId);
|
||||
|
||||
try {
|
||||
await storage.hmset(sessionKey, sessionData);
|
||||
await storage.expire(sessionKey, this.defaultTTL);
|
||||
|
||||
// Add to active sessions set
|
||||
await storage.sadd('gds:stats:sessions:active', sessionId);
|
||||
|
||||
logger.info({
|
||||
type: 'session_created',
|
||||
sessionId,
|
||||
ttl: this.defaultTTL
|
||||
});
|
||||
|
||||
return {
|
||||
id: sessionId,
|
||||
createdAt: now,
|
||||
expiresAt,
|
||||
ttl: this.defaultTTL
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error({ error, sessionId }, 'Failed to create session');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get session data
|
||||
* @param {string} sessionId - Session identifier
|
||||
* @returns {Promise<Object>} Session data
|
||||
* @throws {SessionError} If session not found or expired
|
||||
*/
|
||||
async getSession(sessionId) {
|
||||
const sessionKey = this.getSessionKey(sessionId);
|
||||
|
||||
try {
|
||||
const exists = await storage.exists(sessionKey);
|
||||
|
||||
if (!exists) {
|
||||
throw new SessionError('Session not found or expired', true);
|
||||
}
|
||||
|
||||
const sessionData = await storage.hgetall(sessionKey);
|
||||
|
||||
return {
|
||||
id: sessionId,
|
||||
createdAt: parseInt(sessionData.createdAt, 10),
|
||||
expiresAt: parseInt(sessionData.expiresAt, 10),
|
||||
lastActivity: parseInt(sessionData.lastActivity, 10),
|
||||
bookingCount: parseInt(sessionData.bookingCount, 10),
|
||||
searchCount: parseInt(sessionData.searchCount, 10)
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof SessionError) {
|
||||
throw error;
|
||||
}
|
||||
logger.error({ error, sessionId }, 'Failed to get session');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate session exists and is not expired
|
||||
* @param {string} sessionId - Session identifier
|
||||
* @returns {Promise<boolean>} True if valid
|
||||
* @throws {SessionError} If session invalid or expired
|
||||
*/
|
||||
async validateSession(sessionId) {
|
||||
await this.getSession(sessionId); // Will throw if invalid
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update session activity timestamp (refreshes TTL)
|
||||
* @param {string} sessionId - Session identifier
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async updateActivity(sessionId) {
|
||||
const sessionKey = this.getSessionKey(sessionId);
|
||||
const now = Date.now();
|
||||
|
||||
try {
|
||||
await storage.hset(sessionKey, 'lastActivity', now.toString());
|
||||
await storage.expire(sessionKey, this.defaultTTL);
|
||||
|
||||
logger.debug({
|
||||
type: 'session_activity_updated',
|
||||
sessionId,
|
||||
timestamp: now
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error({ error, sessionId }, 'Failed to update session activity');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Increment booking counter for session
|
||||
* @param {string} sessionId - Session identifier
|
||||
* @returns {Promise<number>} New booking count
|
||||
*/
|
||||
async incrementBookingCount(sessionId) {
|
||||
const sessionKey = this.getSessionKey(sessionId);
|
||||
|
||||
try {
|
||||
const sessionData = await storage.hgetall(sessionKey);
|
||||
const newCount = parseInt(sessionData.bookingCount || '0', 10) + 1;
|
||||
await storage.hset(sessionKey, 'bookingCount', newCount.toString());
|
||||
|
||||
return newCount;
|
||||
} catch (error) {
|
||||
logger.error({ error, sessionId }, 'Failed to increment booking count');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Increment search counter for session
|
||||
* @param {string} sessionId - Session identifier
|
||||
* @returns {Promise<number>} New search count
|
||||
*/
|
||||
async incrementSearchCount(sessionId) {
|
||||
const sessionKey = this.getSessionKey(sessionId);
|
||||
|
||||
try {
|
||||
const sessionData = await storage.hgetall(sessionKey);
|
||||
const newCount = parseInt(sessionData.searchCount || '0', 10) + 1;
|
||||
await storage.hset(sessionKey, 'searchCount', newCount.toString());
|
||||
|
||||
return newCount;
|
||||
} catch (error) {
|
||||
logger.error({ error, sessionId }, 'Failed to increment search count');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* End session (delete from storage)
|
||||
* @param {string} sessionId - Session identifier
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async endSession(sessionId) {
|
||||
const sessionKey = this.getSessionKey(sessionId);
|
||||
|
||||
try {
|
||||
await storage.del(sessionKey);
|
||||
await storage.srem('gds:stats:sessions:active', sessionId);
|
||||
|
||||
logger.info({
|
||||
type: 'session_ended',
|
||||
sessionId
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error({ error, sessionId }, 'Failed to end session');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate unique session ID
|
||||
* @returns {string} UUID v4 session ID
|
||||
*/
|
||||
generateSessionId() {
|
||||
// Using crypto.randomUUID() which is available in Node.js 20+
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Valkey key for session
|
||||
* @param {string} sessionId - Session identifier
|
||||
* @returns {string} Valkey key
|
||||
*/
|
||||
getSessionKey(sessionId) {
|
||||
return `gds:session:${sessionId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Valkey key for session bookings set
|
||||
* @param {string} sessionId - Session identifier
|
||||
* @returns {string} Valkey key
|
||||
*/
|
||||
getBookingsKey(sessionId) {
|
||||
return `gds:session:${sessionId}:bookings`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Valkey key for specific booking
|
||||
* @param {string} sessionId - Session identifier
|
||||
* @param {string} pnr - PNR code
|
||||
* @returns {string} Valkey key
|
||||
*/
|
||||
getBookingKey(sessionId, pnr) {
|
||||
return `gds:session:${sessionId}:booking:${pnr}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const sessionManager = new SessionManager();
|
||||
|
||||
export default sessionManager;
|
||||
289
src/session/storage.ts
Normal file
289
src/session/storage.ts
Normal file
@@ -0,0 +1,289 @@
|
||||
import { Redis } from 'ioredis';
|
||||
import { logger } from '../utils/logger.js';
|
||||
import { StorageError } from '../utils/errors.js';
|
||||
import { log } from 'node:console';
|
||||
|
||||
/**
|
||||
* Valkey client wrapper with connection pooling and error handling
|
||||
*/
|
||||
class ValkeyStorage {
|
||||
client: any;
|
||||
isConnected: boolean;
|
||||
events: Map<string, any>;
|
||||
|
||||
constructor() {
|
||||
this.client = null;
|
||||
this.isConnected = false;
|
||||
// Event store for MCP resumability (Map<string, { streamId: string, message: object }>)
|
||||
this.events = new Map();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize Valkey connection
|
||||
* @param {Object} config - Valkey configuration
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async connect(config = {}) {
|
||||
logger.info({ config }, 'Initializing Valkey connection with config');
|
||||
const defaultConfig = {
|
||||
host: process.env.VALKEY_HOST || 'localhost',
|
||||
port: parseInt(process.env.VALKEY_PORT || '6379', 10),
|
||||
password: process.env.VALKEY_PASSWORD || undefined,
|
||||
db: parseInt(process.env.VALKEY_DB || '0', 10),
|
||||
retryStrategy: (times) => {
|
||||
const delay = Math.min(times * 50, 2000);
|
||||
return delay;
|
||||
},
|
||||
maxRetriesPerRequest: 3,
|
||||
enableReadyCheck: true,
|
||||
lazyConnect: false
|
||||
};
|
||||
|
||||
const finalConfig = { ...defaultConfig, ...config };
|
||||
|
||||
try {
|
||||
this.client = new Redis(finalConfig);
|
||||
|
||||
this.client.on('connect', () => {
|
||||
logger.info('Valkey client connecting...');
|
||||
});
|
||||
|
||||
this.client.on('ready', () => {
|
||||
this.isConnected = true;
|
||||
logger.info('Valkey client connected and ready');
|
||||
});
|
||||
|
||||
this.client.on('error', (err) => {
|
||||
logger.error({ error: err }, 'Valkey client error');
|
||||
this.isConnected = false;
|
||||
});
|
||||
|
||||
this.client.on('close', () => {
|
||||
logger.warn('Valkey connection closed');
|
||||
this.isConnected = false;
|
||||
});
|
||||
|
||||
this.client.on('reconnecting', () => {
|
||||
logger.info('Valkey client reconnecting...');
|
||||
});
|
||||
|
||||
// Wait for connection to be ready
|
||||
await this.client.ping();
|
||||
logger.info('Valkey connection established successfully');
|
||||
} catch (error) {
|
||||
logger.error({ error }, 'Failed to connect to Valkey');
|
||||
throw new StorageError('Failed to connect to Valkey', { error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Close Valkey connection
|
||||
*/
|
||||
async disconnect() {
|
||||
if (this.client) {
|
||||
await this.client.quit();
|
||||
this.isConnected = false;
|
||||
logger.info('Valkey client disconnected');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a key-value pair
|
||||
* @param {string} key - Key
|
||||
* @param {string} value - Value
|
||||
* @param {number} ttl - Time to live in seconds (optional)
|
||||
* @returns {Promise<string>}
|
||||
*/
|
||||
async set(key, value, ttl = null) {
|
||||
try {
|
||||
if (ttl) {
|
||||
return await this.client.setex(key, ttl, value);
|
||||
}
|
||||
return await this.client.set(key, value);
|
||||
} catch (error) {
|
||||
throw new StorageError(`Failed to set key '${key}'`, { error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get value by key
|
||||
* @param {string} key - Key
|
||||
* @returns {Promise<string|null>}
|
||||
*/
|
||||
async get(key) {
|
||||
try {
|
||||
return await this.client.get(key);
|
||||
} catch (error) {
|
||||
throw new StorageError(`Failed to get key '${key}'`, { error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a key
|
||||
* @param {string} key - Key
|
||||
* @returns {Promise<number>} Number of keys deleted
|
||||
*/
|
||||
async del(key) {
|
||||
try {
|
||||
return await this.client.del(key);
|
||||
} catch (error) {
|
||||
throw new StorageError(`Failed to delete key '${key}'`, { error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if key exists
|
||||
* @param {string} key - Key
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
async exists(key) {
|
||||
try {
|
||||
const result = await this.client.exists(key);
|
||||
return result === 1;
|
||||
} catch (error) {
|
||||
throw new StorageError(`Failed to check existence of key '${key}'`, { error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set hash field
|
||||
* @param {string} key - Hash key
|
||||
* @param {string} field - Field name
|
||||
* @param {string} value - Field value
|
||||
* @returns {Promise<number>}
|
||||
*/
|
||||
async hset(key, field, value) {
|
||||
try {
|
||||
return await this.client.hset(key, field, value);
|
||||
} catch (error) {
|
||||
throw new StorageError(`Failed to set hash field '${field}' in '${key}'`, { error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get hash field
|
||||
* @param {string} key - Hash key
|
||||
* @param {string} field - Field name
|
||||
* @returns {Promise<string|null>}
|
||||
*/
|
||||
async hget(key, field) {
|
||||
try {
|
||||
return await this.client.hget(key, field);
|
||||
} catch (error) {
|
||||
throw new StorageError(`Failed to get hash field '${field}' from '${key}'`, { error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all hash fields
|
||||
* @param {string} key - Hash key
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
async hgetall(key) {
|
||||
try {
|
||||
return await this.client.hgetall(key);
|
||||
} catch (error) {
|
||||
throw new StorageError(`Failed to get all hash fields from '${key}'`, { error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set multiple hash fields
|
||||
* @param {string} key - Hash key
|
||||
* @param {Object} data - Field-value pairs
|
||||
* @returns {Promise<string>}
|
||||
*/
|
||||
async hmset(key, data) {
|
||||
try {
|
||||
return await this.client.hmset(key, data);
|
||||
} catch (error) {
|
||||
throw new StorageError(`Failed to set multiple hash fields in '${key}'`, { error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add member to set
|
||||
* @param {string} key - Set key
|
||||
* @param {string} member - Member to add
|
||||
* @returns {Promise<number>}
|
||||
*/
|
||||
async sadd(key, member) {
|
||||
try {
|
||||
return await this.client.sadd(key, member);
|
||||
} catch (error) {
|
||||
throw new StorageError(`Failed to add member to set '${key}'`, { error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove member from set
|
||||
* @param {string} key - Set key
|
||||
* @param {string} member - Member to remove
|
||||
* @returns {Promise<number>}
|
||||
*/
|
||||
async srem(key, member) {
|
||||
try {
|
||||
return await this.client.srem(key, member);
|
||||
} catch (error) {
|
||||
throw new StorageError(`Failed to remove member from set '${key}'`, { error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all set members
|
||||
* @param {string} key - Set key
|
||||
* @returns {Promise<string[]>}
|
||||
*/
|
||||
async smembers(key) {
|
||||
try {
|
||||
return await this.client.smembers(key);
|
||||
} catch (error) {
|
||||
throw new StorageError(`Failed to get members from set '${key}'`, { error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set TTL on key
|
||||
* @param {string} key - Key
|
||||
* @param {number} seconds - TTL in seconds
|
||||
* @returns {Promise<number>}
|
||||
*/
|
||||
async expire(key, seconds) {
|
||||
try {
|
||||
return await this.client.expire(key, seconds);
|
||||
} catch (error) {
|
||||
throw new StorageError(`Failed to set TTL on key '${key}'`, { error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Increment counter
|
||||
* @param {string} key - Key
|
||||
* @returns {Promise<number>}
|
||||
*/
|
||||
async incr(key) {
|
||||
try {
|
||||
return await this.client.incr(key);
|
||||
} catch (error) {
|
||||
throw new StorageError(`Failed to increment key '${key}'`, { error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get multiple keys by pattern
|
||||
* @param {string} pattern - Key pattern (e.g., 'gds:session:*')
|
||||
* @returns {Promise<string[]>}
|
||||
*/
|
||||
async keys(pattern) {
|
||||
try {
|
||||
return await this.client.keys(pattern);
|
||||
} catch (error) {
|
||||
throw new StorageError(`Failed to get keys matching pattern '${pattern}'`, { error: error.message });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const storage = new ValkeyStorage();
|
||||
|
||||
export default storage;
|
||||
104
src/session/valkey-event-store.ts
Normal file
104
src/session/valkey-event-store.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { storage } from './storage.js';
|
||||
import { logger } from '../utils/logger.js';
|
||||
import type { EventStore, JSONRPCMessage } from '@modelcontextprotocol/server';
|
||||
|
||||
/**
|
||||
* Valkey-based Event Store for MCP resumability
|
||||
* Stores server-sent events for reconnection support
|
||||
*/
|
||||
export class ValkeyEventStore implements EventStore {
|
||||
prefix: string;
|
||||
ttl: number;
|
||||
|
||||
constructor() {
|
||||
this.prefix = 'mcp:events:';
|
||||
this.ttl = 3600; // Events expire after 1 hour
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a unique event ID for a given stream ID
|
||||
*/
|
||||
private generateEventId(streamId: string): number {
|
||||
return Date.now();
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the stream ID from an event ID
|
||||
*/
|
||||
private getStreamIdFromEventId(eventId: string): string {
|
||||
const parts = eventId.split('_');
|
||||
return parts.length > 0 ? parts[0]! : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Stores an event with a generated event ID
|
||||
* Implements EventStore.storeEvent
|
||||
*/
|
||||
async storeEvent(streamId: string, message: JSONRPCMessage): Promise<string> {
|
||||
logger.debug({ streamId, message }, 'Storing event in ValkeyEventStore');
|
||||
const eventId = this.generateEventId(streamId);
|
||||
const key = `${this.prefix}${streamId}`;
|
||||
const value = JSON.stringify({ eventId, message, timestamp: Date.now() });
|
||||
|
||||
// Add to sorted set with event ID as score for ordering
|
||||
await storage.client.zadd(key, eventId, value);
|
||||
|
||||
// Set expiration on the key
|
||||
await storage.client.expire(key, this.ttl);
|
||||
|
||||
|
||||
return "" + eventId;
|
||||
}
|
||||
/**
|
||||
* Replays events that occurred after a specific event ID
|
||||
* Implements EventStore.replayEventsAfter
|
||||
*/
|
||||
async replayEventsAfter(
|
||||
lastEventId: string,
|
||||
{ send }: { send: (eventId: string, message: JSONRPCMessage) => Promise<void> }
|
||||
): Promise<string> {
|
||||
const streamId = this.getStreamIdFromEventId(lastEventId);
|
||||
const key = `${this.prefix}${streamId}`;
|
||||
|
||||
// Retrieve all events from the sorted set that come after lastEventId
|
||||
// Using ZRANGEBYSCORE to get events with scores > lastEventId
|
||||
const events = await storage.client.zrangebyscore(
|
||||
key,
|
||||
`(${lastEventId}`, // Exclusive range - events after lastEventId
|
||||
'+inf' // Up to the highest score
|
||||
);
|
||||
|
||||
if (!events || events.length === 0) {
|
||||
logger.debug({ streamId, lastEventId }, 'No events to replay');
|
||||
return lastEventId;
|
||||
}
|
||||
|
||||
let latestEventId = lastEventId;
|
||||
|
||||
// Replay each event in order
|
||||
for (const eventData of events) {
|
||||
try {
|
||||
const parsed = JSON.parse(eventData);
|
||||
const { eventId, message } = parsed;
|
||||
|
||||
// Send the event using the provided callback
|
||||
await send(eventId, message);
|
||||
|
||||
latestEventId = eventId;
|
||||
logger.debug({ streamId, eventId }, 'Event replayed');
|
||||
} catch (error) {
|
||||
logger.error({ error, eventData }, 'Failed to replay event');
|
||||
// Continue with next event even if one fails
|
||||
}
|
||||
}
|
||||
|
||||
logger.debug(
|
||||
{ streamId, lastEventId, latestEventId, count: events.length },
|
||||
'Event replay completed'
|
||||
);
|
||||
|
||||
return latestEventId;
|
||||
}
|
||||
}
|
||||
|
||||
export default ValkeyEventStore;
|
||||
Reference in New Issue
Block a user