@kya-os/mcp-i
Version:
The TypeScript MCP framework with identity features built-in
273 lines (272 loc) • 10.6 kB
JavaScript
"use strict";
/**
* Handshake and Session Management for XMCP-I Runtime
*
* Handles handshake enforcement, session management, and nonce validation
* according to requirements 4.5-4.9 and 19.1-19.2.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.defaultSessionManager = exports.SessionManager = void 0;
exports.createHandshakeRequest = createHandshakeRequest;
exports.validateHandshakeFormat = validateHandshakeFormat;
const crypto_1 = require("crypto");
const memory_nonce_cache_1 = require("../cache/memory-nonce-cache");
/**
* Session manager class
*/
class SessionManager {
config;
sessions = new Map();
constructor(config = {}) {
this.config = {
timestampSkewSeconds: parseInt(process.env.XMCP_I_TS_SKEW_SEC || "120"),
sessionTtlMinutes: parseInt(process.env.XMCP_I_SESSION_TTL_MIN || "30"),
// absoluteSessionLifetime: undefined, // Disabled by default (omit to use undefined)
nonceCache: new memory_nonce_cache_1.MemoryNonceCache(),
...config,
};
// Warn about multi-instance deployments with memory cache (only in production or if explicitly enabled)
if (this.config.nonceCache instanceof memory_nonce_cache_1.MemoryNonceCache &&
(process.env.NODE_ENV === "production" ||
process.env.MCPI_WARN_MEMORY_CACHE === "true")) {
console.warn("Warning: Using MemoryNonceCache - not suitable for multi-instance deployments. " +
"Consider using Redis, DynamoDB, or Cloudflare KV for production.");
}
}
/**
* Set server DID for session creation
* Called after identity is loaded
*/
setServerDid(serverDid) {
this.config.serverDid = serverDid;
}
/**
* Validate handshake and create or retrieve session
* Requirements: 4.5, 4.6, 4.7, 4.8, 4.9
*/
async validateHandshake(request) {
try {
// Validate timestamp (±120s clock skew by default)
const now = Math.floor(Date.now() / 1000);
const timeDiff = Math.abs(now - request.timestamp);
if (timeDiff > this.config.timestampSkewSeconds) {
return {
success: false,
error: {
code: "XMCP_I_EHANDSHAKE",
message: `Timestamp outside acceptable range (±${this.config.timestampSkewSeconds}s)`,
remediation: `Check NTP sync on client and server. Current server time: ${now}, received: ${request.timestamp}, diff: ${timeDiff}s. Adjust XMCP_I_TS_SKEW_SEC if needed.`,
},
};
}
// Validate nonce (must be unique within session window)
const nonceExists = await this.config.nonceCache.has(request.nonce, request.agentDid);
if (nonceExists) {
return {
success: false,
error: {
code: "XMCP_I_EHANDSHAKE",
message: "Nonce already used (replay attack prevention)",
remediation: "Generate a new unique nonce for each request",
},
};
}
// Add nonce to cache with TTL >= session TTL
const nonceTtlSeconds = this.config.sessionTtlMinutes * 60 + 60; // Session TTL + 1 minute buffer
await this.config.nonceCache.add(request.nonce, nonceTtlSeconds, request.agentDid);
// Generate session ID
const sessionId = this.generateSessionId();
const clientInfo = this.buildClientInfo(request);
// Create session context
// Phase 5: Sessions start anonymous until OAuth completes
const session = {
sessionId,
audience: request.audience,
nonce: request.nonce,
timestamp: request.timestamp,
createdAt: now,
lastActivity: now,
ttlMinutes: this.config.sessionTtlMinutes,
identityState: "anonymous", // Phase 5: Anonymous until OAuth
agentDid: request.agentDid, // Pass through agent DID for delegation verification
...(this.config.serverDid && { serverDid: this.config.serverDid }), // Include server DID if provided
...(clientInfo && { clientInfo }),
};
// Store session
this.sessions.set(sessionId, session);
return {
success: true,
session,
};
}
catch (error) {
return {
success: false,
error: {
code: "XMCP_I_EHANDSHAKE",
message: `Handshake validation failed: ${error instanceof Error ? error.message : "Unknown error"}`,
},
};
}
}
/**
* Get session by ID and update last activity
*/
async getSession(sessionId) {
const session = this.sessions.get(sessionId);
if (!session) {
return null;
}
const now = Math.floor(Date.now() / 1000);
// Check if session has expired (idle timeout)
const idleTimeSeconds = now - session.lastActivity;
const maxIdleSeconds = session.ttlMinutes * 60;
if (idleTimeSeconds > maxIdleSeconds) {
this.sessions.delete(sessionId);
return null;
}
// Check absolute session lifetime if configured
if (this.config.absoluteSessionLifetime) {
const sessionAgeSeconds = now - session.createdAt;
const maxAgeSeconds = this.config.absoluteSessionLifetime * 60;
if (sessionAgeSeconds > maxAgeSeconds) {
this.sessions.delete(sessionId);
return null;
}
}
// Update last activity
session.lastActivity = now;
this.sessions.set(sessionId, session);
return session;
}
/**
* Generate a unique session ID
*/
generateSessionId() {
const timestamp = Date.now().toString(36);
const random = (0, crypto_1.randomBytes)(8).toString("hex");
return `sess_${timestamp}_${random}`;
}
/**
* Generate a deterministic client identifier when the client
* does not provide one during the handshake.
*/
generateClientId() {
return `client_${(0, crypto_1.randomBytes)(6).toString("hex")}`;
}
/**
* Normalize string fields from handshake metadata
*/
normalizeClientInfoString(value) {
if (typeof value !== "string") {
return undefined;
}
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : undefined;
}
/**
* Build MCP client metadata for the session when provided during handshake
*/
buildClientInfo(request) {
const hasMetadata = !!request.clientInfo ||
typeof request.clientProtocolVersion === "string" ||
request.clientCapabilities !== undefined;
if (!hasMetadata) {
return undefined;
}
const source = request.clientInfo;
return {
name: this.normalizeClientInfoString(source?.name) ?? "unknown",
title: this.normalizeClientInfoString(source?.title),
version: this.normalizeClientInfoString(source?.version),
platform: this.normalizeClientInfoString(source?.platform),
vendor: this.normalizeClientInfoString(source?.vendor),
persistentId: this.normalizeClientInfoString(source?.persistentId),
clientId: this.normalizeClientInfoString(source?.clientId) ??
this.generateClientId(),
protocolVersion: this.normalizeClientInfoString(request.clientProtocolVersion),
capabilities: request.clientCapabilities,
};
}
/**
* Generate a cryptographically secure nonce
*/
static generateNonce() {
return (0, crypto_1.randomBytes)(16).toString("base64url"); // 128-bit nonce
}
/**
* Cleanup expired sessions and nonces
*/
async cleanup() {
const now = Math.floor(Date.now() / 1000);
// Clean up expired sessions
for (const [sessionId, session] of this.sessions.entries()) {
const idleTimeSeconds = now - session.lastActivity;
const maxIdleSeconds = session.ttlMinutes * 60;
let expired = idleTimeSeconds > maxIdleSeconds;
// Check absolute lifetime
if (!expired && this.config.absoluteSessionLifetime) {
const sessionAgeSeconds = now - session.createdAt;
const maxAgeSeconds = this.config.absoluteSessionLifetime * 60;
expired = sessionAgeSeconds > maxAgeSeconds;
}
if (expired) {
this.sessions.delete(sessionId);
}
}
// Clean up expired nonces
await this.config.nonceCache.cleanup();
}
/**
* Get session statistics
*/
getStats() {
return {
activeSessions: this.sessions.size,
config: {
timestampSkewSeconds: this.config.timestampSkewSeconds,
sessionTtlMinutes: this.config.sessionTtlMinutes,
absoluteSessionLifetime: this.config.absoluteSessionLifetime,
cacheType: this.config.nonceCache.constructor.name,
},
};
}
/**
* Clear all sessions (useful for testing)
*/
clearSessions() {
this.sessions.clear();
}
}
exports.SessionManager = SessionManager;
/**
* Default session manager instance
*/
exports.defaultSessionManager = new SessionManager();
/**
* Utility functions
*/
/**
* Create a handshake request
*/
function createHandshakeRequest(audience) {
return {
nonce: SessionManager.generateNonce(),
audience,
timestamp: Math.floor(Date.now() / 1000),
};
}
/**
* Validate handshake request format
*/
function validateHandshakeFormat(request) {
return (typeof request === "object" &&
request !== null &&
typeof request.nonce === "string" &&
request.nonce.length > 0 &&
typeof request.audience === "string" &&
request.audience.length > 0 &&
typeof request.timestamp === "number" &&
request.timestamp > 0 &&
Number.isInteger(request.timestamp));
}