fixing jsonSchema validation by using zod

This commit is contained in:
2026-04-11 22:23:25 -05:00
parent 0bae26ae0b
commit eb0a4e8308
56 changed files with 12275 additions and 287 deletions

240
src/session/manager.ts Normal file
View 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
View 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;

View 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;