2025-10-26 10:54:13 +00:00
#!/usr/bin/env node
import { Server } from '@modelcontextprotocol/sdk/server/index.js' ;
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js' ;
import {
CallToolRequestSchema ,
ListToolsRequestSchema ,
ListResourcesRequestSchema ,
ReadResourceRequestSchema ,
} from '@modelcontextprotocol/sdk/types.js' ;
import HPRDataLoader from './data-loader.js' ;
// Initialize data loader
const dataLoader = new HPRDataLoader ( ) ;
await dataLoader . load ( ) ;
// Create MCP server
const server = new Server (
{
name : 'hpr-knowledge-base' ,
version : '1.0.0' ,
} ,
{
capabilities : {
tools : { } ,
resources : { } ,
} ,
}
) ;
// 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 ;
let result = ` # HPR ${ String ( episode . id ) . padStart ( 4 , '0' ) } : ${ episode . title }
* * 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 ) {
result += ` \n \n ## Series
* * $ { seriesInfo . name } * * : $ { stripHtml ( seriesInfo . description ) } ` ;
}
if ( includeNotes && episode . notes ) {
result += ` \n \n ## Host Notes \n ${ stripHtml ( episode . notes ) } ` ;
}
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:54:13 +00:00
// List available resources
server . setRequestHandler ( ListResourcesRequestSchema , async ( ) => {
const stats = dataLoader . getStats ( ) ;
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:54:13 +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:54:13 +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:54:13 +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:54:13 +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:54:13 +00:00
} ,
2025-10-26 14:46:24 +00:00
required : [ ] ,
2025-10-26 10:54:13 +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:54:13 +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:54:13 +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:54:13 +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:54:13 +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:54:13 +00:00
return {
content : [
{
type : 'text' ,
2025-10-26 14:46:24 +00:00
text : ` No transcripts found matching ${ description } . ` ,
2025-10-26 10:54:13 +00:00
} ,
] ,
} ;
}
2025-10-26 14:46:24 +00:00
const formatArgs = {
... args ,
contextLines : searchOptions . contextLines ,
maxMatchesPerEpisode : searchOptions . maxMatchesPerEpisode ,
} ;
2025-10-26 10:54:13 +00:00
2025-10-26 14:46:24 +00:00
const text = formatTranscriptSearchResults ( results , formatArgs ) ;
2025-10-26 10:54:13 +00:00
return {
content : [
{
type : 'text' ,
2025-10-26 14:46:24 +00:00
text ,
2025-10-26 10:54:13 +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.' ,
} ,
] ,
} ;
}
let text = ` # ${ host . host }
* * 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 ,
} ;
}
} ) ;
// Start the server
async function main ( ) {
const transport = new StdioServerTransport ( ) ;
await server . connect ( transport ) ;
console . error ( 'HPR Knowledge Base MCP server running on stdio' ) ;
}
main ( ) . catch ( ( error ) => {
console . error ( 'Fatal error:' , error ) ;
process . exit ( 1 ) ;
} ) ;