2025-10-26 10:57:39 +00:00
#!/usr/bin/env node
import express from 'express' ;
import cors from 'cors' ;
import compression from 'compression' ;
import rateLimit from 'express-rate-limit' ;
import { Server } from '@modelcontextprotocol/sdk/server/index.js' ;
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js' ;
import {
CallToolRequestSchema ,
ListToolsRequestSchema ,
ListResourcesRequestSchema ,
ReadResourceRequestSchema ,
} from '@modelcontextprotocol/sdk/types.js' ;
import HPRDataLoader from './data-loader.js' ;
// Configuration
const PORT = process . env . PORT || 3000 ;
const MAX_CONCURRENT_REQUESTS = 10 ;
2025-10-26 14:09:01 +00:00
// Increased the timeout for the long-lived SSE connection connect() call
const REQUEST_TIMEOUT_MS = 60000 ; // 60 seconds (was 30s)
2025-10-26 10:57:39 +00:00
const RATE_LIMIT_WINDOW_MS = 60000 ; // 1 minute
2025-10-26 14:09:01 +00:00
const RATE_LIMIT_MAX_REQUESTS = 100 ; // 100 requests per minute per IP
2025-10-26 10:57:39 +00:00
const MEMORY_THRESHOLD_MB = 450 ;
const CIRCUIT_BREAKER_THRESHOLD = 5 ;
2025-10-26 14:09:01 +00:00
const CIRCUIT_BREAKER_TIMEOUT_MS = 60000 ; // 60 seconds (how long it stays OPEN)
2025-10-26 12:50:18 +00:00
const SSE_HEARTBEAT_INTERVAL_MS = 20000 ; // 20 seconds to prevent proxy timeout
2025-10-26 10:57:39 +00:00
// Initialize data loader
console . error ( 'Loading HPR knowledge base data...' );
const dataLoader = new HPRDataLoader ();
await dataLoader . load ();
console . error ( 'Data loaded successfully!' );
2025-10-26 14:09:01 +00:00
// Map to store active SSE transports, keyed by connectionId
const activeSseTransports = new Map ();
2025-10-26 10:57:39 +00:00
// Circuit Breaker class for graceful degradation
class CircuitBreaker {
constructor ( threshold = CIRCUIT_BREAKER_THRESHOLD , timeout = CIRCUIT_BREAKER_TIMEOUT_MS ) {
this . failures = 0 ;
this . threshold = threshold ;
this . timeout = timeout ;
this . state = 'CLOSED' ; // CLOSED, OPEN, HALF_OPEN
this . nextAttempt = Date . now ();
}
async execute ( fn ) {
if ( this . state === 'OPEN' ) {
if ( Date . now () < this . nextAttempt ) {
throw new Error ( 'Service temporarily unavailable. Please try again later.' );
}
this . state = 'HALF_OPEN' ;
}
try {
const result = await fn ();
if ( this . state === 'HALF_OPEN' ) {
this . state = 'CLOSED' ;
this . failures = 0 ;
}
return result ;
} catch ( error ) {
this . failures ++ ;
if ( this . failures >= this . threshold ) {
this . state = 'OPEN' ;
this . nextAttempt = Date . now () + this . timeout ;
console . error ( `Circuit breaker opened after ${ this . failures } failures` );
}
throw error ;
}
}
reset () {
this . failures = 0 ;
this . state = 'CLOSED' ;
}
}
const circuitBreaker = new CircuitBreaker ();
// Request timeout wrapper
function withTimeout ( promise , timeoutMs = REQUEST_TIMEOUT_MS ) {
return Promise . race ([
promise ,
new Promise (( _ , reject ) =>
setTimeout (() => reject ( new Error ( 'Request timeout' )), timeoutMs )
),
]);
}
// Concurrent request limiter
let activeRequests = 0 ;
function checkConcurrency () {
if ( activeRequests >= MAX_CONCURRENT_REQUESTS ) {
throw new Error ( 'Server at capacity. Please try again later.' );
}
}
// Memory monitoring
let memoryWarning = false ;
function checkMemory () {
const usage = process . memoryUsage ();
const heapUsedMB = usage . heapUsed / 1024 / 1024 ;
if ( heapUsedMB > MEMORY_THRESHOLD_MB ) {
if ( ! memoryWarning ) {
console . error ( `High memory usage: ${ heapUsedMB . toFixed ( 2 ) } MB` );
memoryWarning = true ;
}
throw new Error ( 'Server under high load. Please try again later.' );
} else if ( memoryWarning && heapUsedMB < MEMORY_THRESHOLD_MB * 0.8 ) {
memoryWarning = false ;
}
}
setInterval (() => {
const usage = process . memoryUsage ();
const heapUsedMB = usage . heapUsed / 1024 / 1024 ;
console . error ( `Memory: ${ heapUsedMB . toFixed ( 2 ) } MB, Active requests: ${ activeRequests } ` );
}, 30000 );
// Helper function to strip HTML tags
function stripHtml ( html ) {
return html
. replace ( /<[^>]*>/g , '' )
. replace ( / /g , ' ' )
. replace ( /</g , '<' )
. replace ( />/g , '>' )
. replace ( /&/g , '&' )
. replace ( /"/g , '"' )
. trim ();
}
// Helper to format episode for display
function formatEpisode ( episode , includeNotes = false ) {
const host = dataLoader . getHost ( episode . hostid );
const seriesInfo = episode . series !== 0 ? dataLoader . getSeries ( episode . series ) : null ;
2025-11-02 12:51:40 +00:00
let result = `# HPR ${ String ( episode . id ). padStart ( 4 , '0' ) } : ${ episode . title } ` ;
// Add match type indicator for fuzzy matches
if ( episode . matchType === 'fuzzy' ) {
result += ` *(fuzzy match, distance: ${ episode . matchDistance } )*` ;
}
result += `
2025-10-26 10:57:39 +00:00
**Date:** ${ episode . date }
**Host:** ${ host ? . host || 'Unknown' } (ID: ${ episode . hostid } )
**Duration:** ${ Math . floor ( episode . duration / 60 ) } : ${ String ( episode . duration % 60 ). padStart ( 2 , '0' ) }
**Tags:** ${ episode . tags }
**License:** ${ episode . license }
**Downloads:** ${ episode . downloads }
## Summary
${ episode . summary } ` ;
if ( seriesInfo ) {
2025-10-26 14:09:01 +00:00
result += `
## Series
2025-10-26 10:57:39 +00:00
** ${ seriesInfo . name } **: ${ stripHtml ( seriesInfo . description ) } ` ;
}
if ( includeNotes && episode . notes ) {
2025-10-26 14:09:01 +00:00
result += `
## Host Notes
${ stripHtml ( episode . notes ) } ` ;
2025-10-26 10:57:39 +00:00
}
return result ;
}
2025-10-26 14:46:24 +00:00
function formatTranscriptSearchResults ( results , args ) {
if ( results . length === 0 ) {
return '' ;
}
const descriptorParts = [];
if ( args . query ) {
descriptorParts . push ( `phrase=" ${ args . query } "` );
}
if ( Array . isArray ( args . terms ) && args . terms . length > 0 ) {
descriptorParts . push ( `terms=[ ${ args . terms . join ( ', ' ) } ]` );
}
if ( descriptorParts . length === 0 ) {
descriptorParts . push ( '"no explicit query provided"' );
}
const firstSummary = results [ 0 ] ? . matchSummary || {};
const matchMode = firstSummary . matchMode || 'phrase' ;
const contextLines = args . contextLines ?? 3 ;
const caseSensitive = args . caseSensitive ? 'yes' : 'no' ;
const wholeWord = args . wholeWord ? 'yes' : 'no' ;
const maxMatches = args . maxMatchesPerEpisode ?? 5 ;
const hostFilters = [];
if ( args . hostId ) hostFilters . push ( `ID ${ args . hostId } ` );
if ( args . hostName ) hostFilters . push ( `name " ${ args . hostName } "` );
let text = `# Transcript Search Results ( ${ results . length } episodes)\n\n` ;
text += `Searching for: ${ descriptorParts . join ( ' | ' ) } \n` ;
text += `Match mode: ${ matchMode } | Context lines: ${ contextLines } | Case sensitive: ${ caseSensitive } | Whole word: ${ wholeWord } \n` ;
text += `Maximum matches per episode: ${ maxMatches } \n` ;
if ( hostFilters . length > 0 ) {
text += `Host filter: ${ hostFilters . join ( ' & ' ) } \n` ;
}
text += '\n## Summary\n' ;
text += results . map ( result => {
const host = dataLoader . getHost ( result . episode . hostid );
const matchedTerms = result . matchSummary . matchedTerms . length > 0
? result . matchSummary . matchedTerms . join ( ', ' )
: 'N/A' ;
const termCounts = Object . entries ( result . matchSummary . termHitCounts || {});
const termCountText = termCounts . length > 0
? termCounts . map (([ term , count ]) => ` ${ term } : ${ count } ` ). join ( ', ' )
: null ;
const truncatedNote = result . matchSummary . truncated ? ' (truncated)' : '' ;
let line = `- HPR ${ String ( result . episode . id ). padStart ( 4 , '0' ) } : ${ result . episode . title } — ${ result . matchSummary . totalMatches } match ${ result . matchSummary . totalMatches === 1 ? '' : 'es' }${ truncatedNote } ; terms: ${ matchedTerms } ` ;
if ( termCountText ) {
line += ` ( ${ termCountText } )` ;
}
line += ` | Host: ${ host ? . host || 'Unknown' } ( ${ result . episode . date } )` ;
return line ;
}). join ( '\n' );
text += '\n\n' ;
results . forEach ( result => {
const host = dataLoader . getHost ( result . episode . hostid );
const matchedTerms = result . matchSummary . matchedTerms . length > 0
? result . matchSummary . matchedTerms . join ( ', ' )
: 'N/A' ;
const termCounts = Object . entries ( result . matchSummary . termHitCounts || {});
const termCountText = termCounts . length > 0
? termCounts . map (([ term , count ]) => ` ${ term } : ${ count } ` ). join ( ', ' )
: null ;
text += `## HPR ${ String ( result . episode . id ). padStart ( 4 , '0' ) } : ${ result . episode . title }
**Host:** ${ host ? . host || 'Unknown' } | **Date:** ${ result . episode . date }
**Matched terms:** ${ matchedTerms }
**Matches captured:** ${ result . matchSummary . totalMatches }${ result . matchSummary . truncated ? ' (additional matches omitted after reaching limit)' : '' }
` ;
if ( termCountText ) {
text += `**Term counts:** ${ termCountText } \n` ;
}
text += '\n' ;
result . matches . forEach (( match , index ) => {
const termInfo = match . terms && match . terms . length > 0
? ` | terms: ${ match . terms . join ( ', ' ) } `
: '' ;
text += `### Match ${ index + 1 } (line ${ match . lineNumber }${ termInfo } )
\`\`\`
${ match . context }
\`\`\`
` ;
});
});
return text ;
}
2025-10-26 10:57:39 +00:00
// Create MCP server factory
function createMCPServer () {
const server = new Server (
{
name : 'hpr-knowledge-base' ,
version : '1.0.0' ,
},
{
capabilities : {
tools : {},
resources : {},
},
}
);
// List available resources
server . setRequestHandler ( ListResourcesRequestSchema , async () => {
return {
resources : [
{
uri : 'hpr://stats' ,
mimeType : 'text/plain' ,
name : 'HPR Statistics' ,
description : 'Overall statistics about the HPR knowledge base' ,
},
{
uri : 'hpr://episodes/recent' ,
mimeType : 'text/plain' ,
name : 'Recent Episodes' ,
description : 'List of 50 most recent HPR episodes' ,
},
{
uri : 'hpr://hosts/all' ,
mimeType : 'text/plain' ,
name : 'All Hosts' ,
description : 'List of all HPR hosts' ,
},
{
uri : 'hpr://series/all' ,
mimeType : 'text/plain' ,
name : 'All Series' ,
description : 'List of all HPR series' ,
},
],
};
});
// Read a resource
server . setRequestHandler ( ReadResourceRequestSchema , async ( request ) => {
const uri = request . params . uri ;
if ( uri === 'hpr://stats' ) {
const stats = dataLoader . getStats ();
return {
contents : [
{
uri ,
mimeType : 'text/plain' ,
text : `# Hacker Public Radio Statistics
**Total Episodes:** ${ stats . totalEpisodes }
**Total Hosts:** ${ stats . totalHosts }
**Total Comments:** ${ stats . totalComments }
**Total Series:** ${ stats . totalSeries }
**Transcripts Available:** ${ stats . totalTranscripts }
**Date Range:** ${ stats . dateRange . earliest } to ${ stats . dateRange . latest }
Hacker Public Radio is a community-driven podcast released under Creative Commons licenses.
All content is contributed by the community, for the community.` ,
},
],
};
}
if ( uri === 'hpr://episodes/recent' ) {
const recent = dataLoader . searchEpisodes ( '' , { limit : 50 });
const text = recent . map ( ep => {
const host = dataLoader . getHost ( ep . hostid );
return `**HPR ${ String ( ep . id ). padStart ( 4 , '0' ) } ** ( ${ ep . date } ) - ${ ep . title } by ${ host ? . host || 'Unknown' } ` ;
}). join ( '\n' );
return {
contents : [
{
uri ,
mimeType : 'text/plain' ,
text : `# Recent Episodes\n\n ${ text } ` ,
},
],
};
}
if ( uri === 'hpr://hosts/all' ) {
const hosts = dataLoader . hosts
. filter ( h => h . valid === 1 )
. map ( h => {
const episodeCount = dataLoader . getEpisodesByHost ( h . hostid ). length ;
return `** ${ h . host } ** (ID: ${ h . hostid } ) - ${ episodeCount } episodes` ;
})
. join ( '\n' );
return {
contents : [
{
uri ,
mimeType : 'text/plain' ,
text : `# All HPR Hosts\n\n ${ hosts } ` ,
},
],
};
}
if ( uri === 'hpr://series/all' ) {
const series = dataLoader . series
. filter ( s => s . valid === 1 && s . private === 0 )
. map ( s => {
const episodeCount = dataLoader . getEpisodesInSeries ( s . id ). length ;
return `** ${ s . name } ** (ID: ${ s . id } ) - ${ episodeCount } episodes\n ${ stripHtml ( s . description ) } ` ;
})
. join ( '\n\n' );
return {
contents : [
{
uri ,
mimeType : 'text/plain' ,
text : `# All HPR Series\n\n ${ series } ` ,
},
],
};
}
throw new Error ( `Unknown resource: ${ uri } ` );
});
// List available tools
server . setRequestHandler ( ListToolsRequestSchema , async () => {
return {
tools : [
{
name : 'search_episodes' ,
description : 'Search HPR episodes by keywords in title, summary, tags, or host notes. Can filter by host, series, tags, and date range.' ,
2025-10-27 14:45:11 +00:00
annotations : {
readOnlyHint : true ,
openWorldHint : true
},
2025-10-26 10:57:39 +00:00
inputSchema : {
type : 'object' ,
properties : {
query : {
type : 'string' ,
description : 'Search query (searches title, summary, tags, and notes)' ,
},
limit : {
type : 'number' ,
description : 'Maximum number of results to return (default: 20)' ,
},
hostId : {
type : 'number' ,
description : 'Filter by host ID' ,
},
seriesId : {
type : 'number' ,
description : 'Filter by series ID' ,
},
tag : {
type : 'string' ,
description : 'Filter by tag' ,
},
fromDate : {
type : 'string' ,
description : 'Filter episodes from this date (YYYY-MM-DD)' ,
},
toDate : {
type : 'string' ,
description : 'Filter episodes to this date (YYYY-MM-DD)' ,
},
},
required : [],
},
},
{
name : 'get_episode' ,
description : 'Get detailed information about a specific HPR episode including transcript if available' ,
2025-10-27 14:45:11 +00:00
annotations : {
readOnlyHint : true ,
openWorldHint : true
},
2025-10-26 10:57:39 +00:00
inputSchema : {
type : 'object' ,
properties : {
episodeId : {
type : 'number' ,
description : 'Episode ID number' ,
},
includeTranscript : {
type : 'boolean' ,
description : 'Include full transcript if available (default: true)' ,
},
includeComments : {
type : 'boolean' ,
description : 'Include community comments (default: true)' ,
},
},
required : [ 'episodeId' ],
},
},
{
name : 'search_transcripts' ,
2025-10-26 14:46:24 +00:00
description : 'Search through episode transcripts using phrases or multiple terms with AND/OR matching and optional host filters' ,
2025-10-27 14:45:11 +00:00
annotations : {
readOnlyHint : true ,
openWorldHint : true
},
2025-10-26 10:57:39 +00:00
inputSchema : {
type : 'object' ,
properties : {
query : {
type : 'string' ,
2025-10-26 14:46:24 +00:00
description : 'Search phrase to find in transcripts. Combine with terms/matchMode for advanced searches.' ,
},
terms : {
type : 'array' ,
items : { type : 'string' },
description : 'Explicit list of terms to search for; useful when pairing with matchMode "any" or "all".' ,
},
matchMode : {
type : 'string' ,
enum : [ 'any' , 'all' , 'phrase' ],
description : 'How to interpret the query/terms. "phrase" (default) matches the phrase exactly, "any" matches if any term is present, "all" requires every term.' ,
2025-10-26 10:57:39 +00:00
},
limit : {
type : 'number' ,
description : 'Maximum number of episodes to return (default: 20)' ,
},
contextLines : {
type : 'number' ,
description : 'Number of lines of context around matches (default: 3)' ,
},
2025-10-26 14:46:24 +00:00
hostId : {
type : 'number' ,
description : 'Restrict matches to a given host ID.' ,
},
hostName : {
type : 'string' ,
description : 'Restrict matches to hosts whose name contains this value.' ,
},
caseSensitive : {
type : 'boolean' ,
description : 'Perform a case-sensitive search (default: false).' ,
},
wholeWord : {
type : 'boolean' ,
description : 'Match whole words only (default: false).' ,
},
maxMatchesPerEpisode : {
type : 'number' ,
description : 'Maximum number of excerpt matches to include per episode (default: 5).' ,
},
2025-10-26 10:57:39 +00:00
},
2025-10-26 14:46:24 +00:00
required : [],
2025-10-26 10:57:39 +00:00
},
},
{
name : 'get_host_info' ,
description : 'Get information about an HPR host including all their episodes' ,
2025-10-27 14:45:11 +00:00
annotations : {
readOnlyHint : true ,
openWorldHint : true
},
2025-10-26 10:57:39 +00:00
inputSchema : {
type : 'object' ,
properties : {
hostId : {
type : 'number' ,
description : 'Host ID number' ,
},
hostName : {
type : 'string' ,
description : 'Host name (will search if hostId not provided)' ,
},
includeEpisodes : {
type : 'boolean' ,
description : 'Include list of all episodes by this host (default: true)' ,
},
},
required : [],
},
},
{
name : 'get_series_info' ,
description : 'Get information about an HPR series including all episodes in the series' ,
2025-10-27 14:45:11 +00:00
annotations : {
readOnlyHint : true ,
openWorldHint : true
},
2025-10-26 10:57:39 +00:00
inputSchema : {
type : 'object' ,
properties : {
seriesId : {
type : 'number' ,
description : 'Series ID number' ,
},
},
required : [ 'seriesId' ],
},
},
],
};
});
// Handle tool calls
server . setRequestHandler ( CallToolRequestSchema , async ( request ) => {
const { name , arguments : args } = request . params ;
try {
if ( name === 'search_episodes' ) {
const results = dataLoader . searchEpisodes ( args . query || '' , {
limit : args . limit || 20 ,
hostId : args . hostId ,
seriesId : args . seriesId ,
tag : args . tag ,
fromDate : args . fromDate ,
toDate : args . toDate ,
});
const text = results . length > 0
? results . map ( ep => formatEpisode ( ep , false )). join ( '\n\n---\n\n' )
: 'No episodes found matching your search criteria.' ;
return {
content : [
{
type : 'text' ,
text : `# Search Results ( ${ results . length } episodes found)\n\n ${ text } ` ,
},
],
};
}
if ( name === 'get_episode' ) {
const episode = dataLoader . getEpisode ( args . episodeId );
if ( ! episode ) {
return {
content : [
{
type : 'text' ,
text : `Episode ${ args . episodeId } not found.` ,
},
],
};
}
let text = formatEpisode ( episode , true );
// Add transcript if requested and available
if ( args . includeTranscript !== false ) {
const transcript = dataLoader . getTranscript ( args . episodeId );
if ( transcript ) {
text += `\n\n## Transcript\n\n ${ transcript } ` ;
} else {
text += `\n\n## Transcript\n\n*No transcript available for this episode.*` ;
}
}
// Add comments if requested
if ( args . includeComments !== false ) {
const comments = dataLoader . getCommentsForEpisode ( args . episodeId );
if ( comments . length > 0 ) {
text += `\n\n## Comments ( ${ comments . length } )\n\n` ;
text += comments . map ( c =>
`** ${ c . comment_author_name } ** ( ${ c . comment_timestamp } ) ${ c . comment_title ? ` - ${ c . comment_title } ` : '' } \n ${ c . comment_text } `
). join ( '\n\n---\n\n' );
}
}
return {
content : [
{
type : 'text' ,
text ,
},
],
};
}
if ( name === 'search_transcripts' ) {
2025-10-26 14:46:24 +00:00
const searchOptions = {
2025-10-26 10:57:39 +00:00
limit : args . limit || 20 ,
2025-10-26 14:46:24 +00:00
contextLines : args . contextLines ?? 3 ,
terms : args . terms ,
matchMode : args . matchMode ,
hostId : args . hostId ,
hostName : args . hostName ,
caseSensitive : args . caseSensitive ,
wholeWord : args . wholeWord ,
maxMatchesPerEpisode : args . maxMatchesPerEpisode ?? 5 ,
};
const results = dataLoader . searchTranscripts ( args . query || '' , searchOptions );
2025-10-26 10:57:39 +00:00
if ( results . length === 0 ) {
2025-10-26 14:46:24 +00:00
const descriptorParts = [];
if ( args . query ) descriptorParts . push ( `phrase " ${ args . query } "` );
if ( Array . isArray ( args . terms ) && args . terms . length > 0 ) descriptorParts . push ( `terms [ ${ args . terms . join ( ', ' ) } ]` );
if ( args . hostId || args . hostName ) descriptorParts . push ( 'host filter applied' );
const description = descriptorParts . length > 0 ? descriptorParts . join ( ', ' ) : 'the provided criteria' ;
2025-10-26 10:57:39 +00:00
return {
content : [
{
type : 'text' ,
2025-10-26 14:46:24 +00:00
text : `No transcripts found matching ${ description } .` ,
2025-10-26 10:57:39 +00:00
},
],
};
}
2025-10-26 14:46:24 +00:00
const formatArgs = {
... args ,
contextLines : searchOptions . contextLines ,
maxMatchesPerEpisode : searchOptions . maxMatchesPerEpisode ,
};
2025-10-26 10:57:39 +00:00
2025-10-26 14:46:24 +00:00
const text = formatTranscriptSearchResults ( results , formatArgs );
2025-10-26 10:57:39 +00:00
return {
content : [
{
type : 'text' ,
2025-10-26 14:46:24 +00:00
text ,
2025-10-26 10:57:39 +00:00
},
],
};
}
if ( name === 'get_host_info' ) {
let host ;
if ( args . hostId ) {
host = dataLoader . getHost ( args . hostId );
} else if ( args . hostName ) {
const hosts = dataLoader . searchHosts ( args . hostName );
host = hosts [ 0 ];
}
if ( ! host ) {
return {
content : [
{
type : 'text' ,
text : 'Host not found.' ,
},
],
};
}
2025-11-02 12:51:40 +00:00
let text = `# ${ host . host } ` ;
// Add match type indicator for fuzzy matches
if ( host . matchType === 'fuzzy' ) {
text += ` *(fuzzy match, distance: ${ host . matchDistance } )*` ;
}
text += `
2025-10-26 10:57:39 +00:00
**Host ID:** ${ host . hostid }
**Email:** ${ host . email }
**License:** ${ host . license }
**Profile:** ${ stripHtml ( host . profile ) }
` ;
if ( args . includeEpisodes !== false ) {
const episodes = dataLoader . getEpisodesByHost ( host . hostid );
text += `\n**Total Episodes:** ${ episodes . length } \n\n## Episodes\n\n` ;
// Sort by date (newest first)
episodes . sort (( a , b ) => b . date . localeCompare ( a . date ));
text += episodes . map ( ep =>
`**HPR ${ String ( ep . id ). padStart ( 4 , '0' ) } ** ( ${ ep . date } ) - ${ ep . title } \n ${ ep . summary } `
). join ( '\n\n' );
}
return {
content : [
{
type : 'text' ,
text ,
},
],
};
}
if ( name === 'get_series_info' ) {
const series = dataLoader . getSeries ( args . seriesId );
if ( ! series ) {
return {
content : [
{
type : 'text' ,
text : `Series ${ args . seriesId } not found.` ,
},
],
};
}
const episodes = dataLoader . getEpisodesInSeries ( args . seriesId );
let text = `# ${ series . name }
**Series ID:** ${ series . id }
**Description:** ${ stripHtml ( series . description ) }
**Total Episodes:** ${ episodes . length }
## Episodes in Series
` ;
// Sort by date
episodes . sort (( a , b ) => a . date . localeCompare ( b . date ));
text += episodes . map (( ep , index ) => {
const host = dataLoader . getHost ( ep . hostid );
return ` ${ index + 1 } . **HPR ${ String ( ep . id ). padStart ( 4 , '0' ) } ** ( ${ ep . date } ) - ${ ep . title } by ${ host ? . host || 'Unknown' } \n ${ ep . summary } ` ;
}). join ( '\n\n' );
return {
content : [
{
type : 'text' ,
text ,
},
],
};
}
throw new Error ( `Unknown tool: ${ name } ` );
} catch ( error ) {
return {
content : [
{
type : 'text' ,
text : `Error: ${ error . message } ` ,
},
],
isError : true ,
};
}
});
return server ;
}
// Create Express app
const app = express ();
2025-10-26 14:09:01 +00:00
// Create a single MCP server instance
const mcpServer = createMCPServer ();
// Trust first proxy hop (Render/Heroku) without allowing arbitrary spoofing
app . set ( 'trust proxy' , 1 );
2025-10-26 11:32:42 +00:00
2025-10-26 10:57:39 +00:00
// Enable CORS
app . use ( cors ());
// Enable compression
app . use ( compression ());
2025-10-26 14:09:01 +00:00
// Apply JSON body parsing globally for the SDK to read POST bodies.
2025-10-26 13:11:41 +00:00
app . use ( express . json ());
2025-10-26 10:57:39 +00:00
// Rate limiting
const limiter = rateLimit ({
windowMs : RATE_LIMIT_WINDOW_MS ,
max : RATE_LIMIT_MAX_REQUESTS ,
message : 'Too many requests from this IP, please try again later.' ,
standardHeaders : true ,
legacyHeaders : false ,
});
app . use ( limiter );
// Health check endpoint
app . get ( '/health' , ( req , res ) => {
const usage = process . memoryUsage ();
const heapUsedMB = usage . heapUsed / 1024 / 1024 ;
res . json ({
status : 'ok' ,
memory : {
used : ` ${ heapUsedMB . toFixed ( 2 ) } MB` ,
threshold : ` ${ MEMORY_THRESHOLD_MB } MB` ,
},
activeRequests ,
circuitBreaker : circuitBreaker . state ,
});
});
2025-10-26 14:09:01 +00:00
// ⭐ NEW ENDPOINT: Circuit breaker reset
app . post ( '/reset' , ( req , res ) => {
if ( circuitBreaker . state === 'OPEN' ) {
circuitBreaker . reset ();
console . error ( 'Circuit breaker manually reset.' );
res . json ({ status : 'ok' , message : 'Circuit breaker reset to CLOSED.' });
} else {
res . json ({ status : 'ok' , message : 'Circuit breaker already CLOSED.' });
}
});
2025-10-26 10:57:39 +00:00
// SSE endpoint for MCP
app . get ( '/sse' , async ( req , res ) => {
2025-10-26 13:06:03 +00:00
let pingInterval = null ;
2025-10-26 14:09:01 +00:00
let transport ;
2025-10-26 12:50:18 +00:00
2025-10-26 10:57:39 +00:00
try {
2025-10-26 13:11:41 +00:00
// Check system health
2025-10-26 10:57:39 +00:00
checkMemory ();
checkConcurrency ();
activeRequests ++ ;
console . error ( `New SSE connection. Active requests: ${ activeRequests } ` );
2025-10-26 14:09:01 +00:00
// Create SSE transport, specifying the POST message path
transport = new SSEServerTransport ( '/message' , res );
activeSseTransports . set ( transport . sessionId , transport );
// Connect server with timeout and circuit breaker
// This calls transport.start() internally, which sets up headers and sends the endpoint event.
await circuitBreaker . execute (() => mcpServer . connect ( transport ));
2025-10-26 12:50:18 +00:00
2025-10-26 14:09:01 +00:00
// 2. Start the heartbeat/ping interval (after transport.start() has set up res.write)
2025-10-26 12:50:18 +00:00
pingInterval = setInterval (() => {
2025-10-26 13:11:41 +00:00
// Send a comment line every 20s to keep the proxy alive
2025-10-26 12:50:18 +00:00
res . write ( ':\n' );
}, SSE_HEARTBEAT_INTERVAL_MS );
2025-10-26 14:09:01 +00:00
// Handle connection close (will execute when client closes the connection)
2025-10-26 10:57:39 +00:00
req . on ( 'close' , () => {
activeRequests -- ;
2025-10-26 12:50:18 +00:00
if ( pingInterval ) {
2025-10-26 13:11:41 +00:00
clearInterval ( pingInterval );
2025-10-26 12:50:18 +00:00
}
2025-10-26 14:09:01 +00:00
if ( transport ) {
activeSseTransports . delete ( transport . sessionId );
}
2025-10-26 10:57:39 +00:00
console . error ( `SSE connection closed. Active requests: ${ activeRequests } ` );
2025-10-26 14:09:01 +00:00
// Ensure the server stream is ended gracefully if it hasn't already
if ( ! res . writableEnded ) {
res . end ();
}
2025-10-26 10:57:39 +00:00
});
} catch ( error ) {
2025-10-26 14:09:01 +00:00
// Handle error during connection establishment or connection timeout
2025-10-26 10:57:39 +00:00
activeRequests -- ;
2025-10-26 12:50:18 +00:00
if ( pingInterval ) {
2025-10-26 13:11:41 +00:00
clearInterval ( pingInterval );
2025-10-26 12:50:18 +00:00
}
2025-10-26 14:09:01 +00:00
if ( transport ) {
activeSseTransports . delete ( transport . sessionId );
}
2025-10-26 10:57:39 +00:00
console . error ( 'SSE connection error:' , error . message );
2025-10-26 14:09:01 +00:00
if ( ! res . headersSent ) {
2025-10-26 13:06:03 +00:00
// Case 1: Error before SSE headers were flushed (e.g., checkMemory failed)
2025-10-26 14:09:01 +00:00
// We can still set the status code.
2025-10-26 10:57:39 +00:00
res . status ( 503 ). json ({
error : error . message ,
circuitBreaker : circuitBreaker . state ,
});
2025-10-26 13:06:03 +00:00
} else {
// Case 2: Error after SSE headers were flushed (stream is open)
2025-10-26 13:11:41 +00:00
// Send an SSE 'error' event and end the connection.
2025-10-26 13:06:03 +00:00
const errorData = JSON . stringify ({
message : error . message ,
circuitBreaker : circuitBreaker . state
});
res . write ( `event: error\ndata: ${ errorData } \n\n` );
2025-10-26 13:11:41 +00:00
res . end ();
2025-10-26 10:57:39 +00:00
}
}
});
2025-10-26 14:09:01 +00:00
// POST endpoint for MCP messages
app . post ( '/message' , async ( req , res ) => {
2025-10-26 14:24:12 +00:00
const headerConnectionId = req . headers [ 'x-connection-id' ];
const queryConnectionId = req . query . sessionId ;
const connectionId = headerConnectionId || queryConnectionId ;
2025-10-26 14:09:01 +00:00
const transport = activeSseTransports . get ( connectionId );
2025-10-26 13:11:41 +00:00
2025-10-26 14:09:01 +00:00
if ( transport ) {
try {
await transport . handlePostMessage ( req , res , req . body );
} catch ( error ) {
console . error ( 'Error processing MCP message via POST:' , error );
res . status ( 400 ). json ({ error : 'Bad Request' , message : error . message });
}
} else {
res . status ( 404 ). json ({ error : 'Not Found' , message : 'No active SSE connection for this ID.' });
}
});
2025-10-26 10:57:39 +00:00
// Error handling middleware
app . use (( err , req , res , next ) => {
console . error ( 'Express error:' , err );
res . status ( 500 ). json ({
error : 'Internal server error' ,
message : err . message ,
});
});
// Start server
app . listen ( PORT , () => {
console . error ( `HPR Knowledge Base MCP Server running on http://localhost: ${ PORT } ` );
console . error ( `SSE endpoint: http://localhost: ${ PORT } /sse` );
console . error ( `Health check: http://localhost: ${ PORT } /health` );
console . error ( `Configuration:` );
console . error ( ` - Max concurrent requests: ${ MAX_CONCURRENT_REQUESTS } ` );
console . error ( ` - Request timeout: ${ REQUEST_TIMEOUT_MS } ms` );
console . error ( ` - Rate limit: ${ RATE_LIMIT_MAX_REQUESTS } requests per ${ RATE_LIMIT_WINDOW_MS / 1000 } s` );
console . error ( ` - Memory threshold: ${ MEMORY_THRESHOLD_MB } MB` );
2025-10-26 12:50:18 +00:00
console . error ( ` - SSE Heartbeat: ${ SSE_HEARTBEAT_INTERVAL_MS / 1000 } s` );
2025-10-26 10:57:39 +00:00
});
// Graceful shutdown
process . on ( 'SIGTERM' , () => {
console . error ( 'SIGTERM received, shutting down gracefully...' );
process . exit ( 0 );
});
process . on ( 'SIGINT' , () => {
console . error ( 'SIGINT received, shutting down gracefully...' );
process . exit ( 0 );
2025-10-26 14:09:01 +00:00
});