McpService.tsā¢21.7 kB
import { AppDataSource } from '../config/database';
import { Article, ArticleStatus } from '../entities/Article';
import { Feed } from '../entities/Feed';
import { ILike, Between, In } from 'typeorm';
import { EmbeddingService } from './EmbeddingService';
interface ArticleItem {
id: number;
title: string;
content?: string; // Optional - only included when includeContent=true
link: string;
pubDate: string;
fetchDate: string;
status: string;
feedTitle: string;
feedCategory: string;
excerpt?: string;
similarity?: number;
}
interface ContentResponse {
articles: ArticleItem[];
total?: number;
success: boolean;
message?: string;
}
interface TagResponse {
success: boolean;
message?: string;
}
interface SourcesResponse {
sources: {
id: number;
title: string;
category: string;
url: string;
isFavorite?: boolean;
}[];
total?: number;
success: boolean;
message?: string;
}
export class McpService {
private embeddingService: EmbeddingService | null = null;
constructor() {
// Initialize embedding service only if API key is available and not empty
if (process.env.OPENAI_API_KEY && process.env.OPENAI_API_KEY.trim() !== '') {
try {
this.embeddingService = new EmbeddingService();
} catch (error) {
console.error('Failed to initialize EmbeddingService:', error);
// Service remains null, semantic search will be disabled
}
}
}
/**
* Get all RSS feed sources with pagination
* @param limit - Number of sources to return (default: 50)
* @param offset - Offset for pagination (default: 0)
* @param favoritesOnly - Only show favorite blogs (default: false)
* @param category - Filter by category
* @returns List of feed sources with pagination
*/
async get_sources(
limit: number = 50,
offset: number = 0,
favoritesOnly?: boolean,
category?: string
): Promise<SourcesResponse> {
try {
const feedRepository = AppDataSource.getRepository(Feed);
const queryBuilder = feedRepository.createQueryBuilder('feed')
.orderBy('feed.isFavorite', 'DESC') // Favorite blogs first
.addOrderBy('feed.title', 'ASC')
.skip(offset)
.take(limit);
// Filter by favorites if requested
if (favoritesOnly) {
queryBuilder.where('feed.isFavorite = :isFavorite', { isFavorite: true });
}
// Filter by category if requested
if (category) {
const whereMethod = favoritesOnly ? 'andWhere' : 'where';
queryBuilder[whereMethod]('feed.category ILIKE :category', { category: `%${category}%` });
}
const [feeds, total] = await queryBuilder.getManyAndCount();
const formattedSources = feeds.map(feed => ({
id: feed.id,
title: feed.title,
category: feed.category,
url: feed.url,
isFavorite: feed.isFavorite
}));
return {
sources: formattedSources,
total,
success: true
};
} catch (error) {
return {
sources: [],
success: false,
message: `Failed to get sources: ${error instanceof Error ? error.message : 'Unknown error'}`
};
}
}
/**
* Get article content with basic filtering
* @param statuses - Article status filter (can be multiple)
* @param limit - Number of articles to return
* @param offset - Offset for pagination
* @param source - Feed source filter
* @param favoriteBlogsOnly - Only show articles from favorite blogs
* @param prioritizeFavoriteBlogs - Show favorite blog articles first
* @param includeContent - Include full content in response (default: false)
* @param includeExcerpt - Include excerpt in response (default: false)
* @param startDate - Start date for filtering (ISO string)
* @param endDate - End date for filtering (ISO string)
* @returns Article list with pagination
*/
async get_content(
statuses?: ArticleStatus[],
limit: number = 10,
offset: number = 0,
source?: string,
favoriteBlogsOnly?: boolean,
prioritizeFavoriteBlogs?: boolean,
includeContent: boolean = false,
includeExcerpt: boolean = false,
startDate?: string,
endDate?: string
): Promise<ContentResponse> {
try {
const articleRepository = AppDataSource.getRepository(Article);
const queryBuilder = articleRepository.createQueryBuilder('article')
.leftJoinAndSelect('article.feed', 'feed');
// Order by favorite blogs first if requested, then by pubDate
if (prioritizeFavoriteBlogs) {
queryBuilder
.orderBy('feed.isFavorite', 'DESC')
.addOrderBy('article.pubDate', 'DESC');
} else {
queryBuilder.orderBy('article.pubDate', 'DESC');
}
queryBuilder
.skip(offset)
.take(limit);
if (statuses && statuses.length > 0) {
queryBuilder.where('article.status IN (:...statuses)', { statuses });
}
if (source) {
const whereMethod = statuses && statuses.length > 0 ? 'andWhere' : 'where';
queryBuilder[whereMethod]('feed.title = :source', { source });
}
if (favoriteBlogsOnly) {
const whereMethod = (statuses && statuses.length > 0) || source ? 'andWhere' : 'where';
queryBuilder[whereMethod]('feed.isFavorite = :isFavorite', { isFavorite: true });
}
// Date range filter
if (startDate && endDate) {
const whereMethod = (statuses && statuses.length > 0) || source || favoriteBlogsOnly ? 'andWhere' : 'where';
queryBuilder[whereMethod]('article.pubDate BETWEEN :startDate AND :endDate', { startDate, endDate });
} else if (startDate) {
const whereMethod = (statuses && statuses.length > 0) || source || favoriteBlogsOnly ? 'andWhere' : 'where';
queryBuilder[whereMethod]('article.pubDate >= :startDate', { startDate });
} else if (endDate) {
const whereMethod = (statuses && statuses.length > 0) || source || favoriteBlogsOnly ? 'andWhere' : 'where';
queryBuilder[whereMethod]('article.pubDate <= :endDate', { endDate });
}
const [articles, total] = await queryBuilder.getManyAndCount();
const formattedArticles = articles.map(article => {
const item: ArticleItem = {
id: article.id,
title: article.title,
link: article.link,
pubDate: article.pubDate.toISOString(),
fetchDate: article.fetchDate.toISOString(),
status: article.status,
feedTitle: article.feed?.title || '',
feedCategory: article.feed?.category || ''
};
// Only include excerpt if requested
if (includeExcerpt) {
item.excerpt = this.createExcerpt(article.content);
}
// Only include full content if requested
if (includeContent) {
item.content = article.content;
}
return item;
});
return {
articles: formattedArticles,
total,
success: true
};
} catch (error) {
return {
articles: [],
success: false,
message: `Failed to get articles: ${error instanceof Error ? error.message : 'Unknown error'}`
};
}
}
/**
* Advanced search with keyword, date range, category, and status filtering
* @param keyword - Search term for title/content
* @param category - Feed category filter
* @param statuses - Article statuses to include
* @param startDate - Start date for filtering (ISO string)
* @param endDate - End date for filtering (ISO string)
* @param limit - Number of results
* @param offset - Offset for pagination
* @param favoriteBlogsOnly - Only show articles from favorite blogs
* @param prioritizeFavoriteBlogs - Show favorite blog articles first
* @param includeContent - Include full content in response (default: false)
* @returns Filtered articles
*/
async search_articles(
keyword?: string,
category?: string,
statuses?: ArticleStatus[],
startDate?: string,
endDate?: string,
limit: number = 20,
offset: number = 0,
favoriteBlogsOnly?: boolean,
prioritizeFavoriteBlogs?: boolean,
includeContent: boolean = false
): Promise<ContentResponse> {
try {
const articleRepository = AppDataSource.getRepository(Article);
const queryBuilder = articleRepository.createQueryBuilder('article')
.leftJoinAndSelect('article.feed', 'feed');
// Order by favorite blogs first if requested, then by pubDate
if (prioritizeFavoriteBlogs) {
queryBuilder
.orderBy('feed.isFavorite', 'DESC')
.addOrderBy('article.pubDate', 'DESC');
} else {
queryBuilder.orderBy('article.pubDate', 'DESC');
}
queryBuilder
.skip(offset)
.take(limit);
const conditions: string[] = [];
const parameters: any = {};
// Keyword search in title and content
if (keyword) {
conditions.push('(article.title ILIKE :keyword OR article.content ILIKE :keyword)');
parameters.keyword = `%${keyword}%`;
}
// Category filter
if (category) {
conditions.push('feed.category ILIKE :category');
parameters.category = `%${category}%`;
}
// Status filter
if (statuses && statuses.length > 0) {
conditions.push('article.status IN (:...statuses)');
parameters.statuses = statuses;
}
// Date range filter
if (startDate && endDate) {
conditions.push('article.pubDate BETWEEN :startDate AND :endDate');
parameters.startDate = startDate;
parameters.endDate = endDate;
} else if (startDate) {
conditions.push('article.pubDate >= :startDate');
parameters.startDate = startDate;
} else if (endDate) {
conditions.push('article.pubDate <= :endDate');
parameters.endDate = endDate;
}
// Favorite blogs filter
if (favoriteBlogsOnly) {
conditions.push('feed.isFavorite = :isFavorite');
parameters.isFavorite = true;
}
if (conditions.length > 0) {
queryBuilder.where(conditions.join(' AND '), parameters);
}
const [articles, total] = await queryBuilder.getManyAndCount();
const formattedArticles = articles.map(article => {
const item: ArticleItem = {
id: article.id,
title: article.title,
link: article.link,
pubDate: article.pubDate.toISOString(),
fetchDate: article.fetchDate.toISOString(),
status: article.status,
feedTitle: article.feed?.title || '',
feedCategory: article.feed?.category || '',
excerpt: this.createExcerpt(article.content)
};
// Only include full content if requested
if (includeContent) {
item.content = article.content;
}
return item;
});
return {
articles: formattedArticles,
total,
success: true
};
} catch (error) {
return {
articles: [],
success: false,
message: `Search failed: ${error instanceof Error ? error.message : 'Unknown error'}`
};
}
}
/**
* Semantic search using vector embeddings
* @param query - Natural language search query
* @param limit - Number of results
* @param statuses - Filter by article statuses
* @param category - Filter by category
* @param includeContent - Include full content in response (default: false)
* @returns Semantically similar articles
*/
async semantic_search(
query: string,
limit: number = 10,
statuses?: ArticleStatus[],
category?: string,
includeContent: boolean = false
): Promise<ContentResponse> {
if (!this.embeddingService) {
return {
articles: [],
success: false,
message: 'Semantic search is not available. Please set OPENAI_API_KEY environment variable.'
};
}
try {
// Generate embedding for the query
const queryEmbedding = await this.embeddingService.generateEmbedding(query);
const embeddingString = `[${queryEmbedding.join(',')}]`;
const articleRepository = AppDataSource.getRepository(Article);
// Build query with vector similarity search
let queryBuilder = articleRepository.createQueryBuilder('article')
.leftJoinAndSelect('article.feed', 'feed')
.where('article.embedding IS NOT NULL')
.orderBy('article.embedding <=> :embedding', 'ASC')
.setParameter('embedding', embeddingString)
.take(limit);
// Apply additional filters
if (statuses && statuses.length > 0) {
queryBuilder = queryBuilder.andWhere('article.status IN (:...statuses)', { statuses });
}
if (category) {
queryBuilder = queryBuilder.andWhere('feed.category ILIKE :category', { category: `%${category}%` });
}
const articles = await queryBuilder.getMany();
const formattedArticles = articles.map(article => {
const item: ArticleItem = {
id: article.id,
title: article.title,
link: article.link,
pubDate: article.pubDate.toISOString(),
fetchDate: article.fetchDate.toISOString(),
status: article.status,
feedTitle: article.feed?.title || '',
feedCategory: article.feed?.category || '',
excerpt: this.createExcerpt(article.content)
};
// Only include full content if requested
if (includeContent) {
item.content = article.content;
}
return item;
});
return {
articles: formattedArticles,
success: true
};
} catch (error) {
return {
articles: [],
success: false,
message: `Semantic search failed: ${error instanceof Error ? error.message : 'Unknown error'}`
};
}
}
/**
* Get daily digest of unread articles grouped by category
* @param limit - Max articles per category
* @param includeContent - Include full content in response (default: false)
* @returns Unread articles grouped by category
*/
async get_daily_digest(limit: number = 5, includeContent: boolean = false): Promise<ContentResponse> {
try {
const articleRepository = AppDataSource.getRepository(Article);
// Get all unread articles published today, grouped by category
const today = new Date();
today.setHours(0, 0, 0, 0);
const articles = await articleRepository.createQueryBuilder('article')
.leftJoinAndSelect('article.feed', 'feed')
.where('article.status = :status', { status: ArticleStatus.UNREAD })
.andWhere('article.pubDate >= :today', { today: today.toISOString() })
.orderBy('feed.category', 'ASC')
.addOrderBy('article.pubDate', 'DESC')
.getMany();
// Group by category and limit per category
const grouped = new Map<string, typeof articles>();
articles.forEach(article => {
const category = article.feed?.category || 'Uncategorized';
if (!grouped.has(category)) {
grouped.set(category, []);
}
const categoryArticles = grouped.get(category)!;
if (categoryArticles.length < limit) {
categoryArticles.push(article);
}
});
// Flatten and format
const formattedArticles: ArticleItem[] = [];
grouped.forEach(categoryArticles => {
categoryArticles.forEach(article => {
const item: ArticleItem = {
id: article.id,
title: article.title,
link: article.link,
pubDate: article.pubDate.toISOString(),
fetchDate: article.fetchDate.toISOString(),
status: article.status,
feedTitle: article.feed?.title || '',
feedCategory: article.feed?.category || 'Uncategorized',
excerpt: this.createExcerpt(article.content)
};
// Only include full content if requested
if (includeContent) {
item.content = article.content;
}
formattedArticles.push(item);
});
});
return {
articles: formattedArticles,
total: formattedArticles.length,
success: true
};
} catch (error) {
return {
articles: [],
success: false,
message: `Failed to get daily digest: ${error instanceof Error ? error.message : 'Unknown error'}`
};
}
}
/**
* Get favorite articles from the last week (titles only)
* @returns Favorite articles from last 7 days with titles only
*/
async get_weekly_favorites(): Promise<ContentResponse> {
try {
const articleRepository = AppDataSource.getRepository(Article);
// Calculate date 7 days ago
const oneWeekAgo = new Date();
oneWeekAgo.setDate(oneWeekAgo.getDate() - 7);
const articles = await articleRepository.createQueryBuilder('article')
.leftJoinAndSelect('article.feed', 'feed')
.where('article.status = :status', { status: ArticleStatus.FAVORITE })
.andWhere('article.pubDate >= :oneWeekAgo', { oneWeekAgo: oneWeekAgo.toISOString() })
.orderBy('article.pubDate', 'DESC')
.getMany();
// Return only titles and minimal metadata
const formattedArticles = articles.map(article => ({
id: article.id,
title: article.title,
link: article.link,
pubDate: article.pubDate.toISOString(),
fetchDate: article.fetchDate.toISOString(),
status: article.status,
feedTitle: article.feed?.title || '',
feedCategory: article.feed?.category || ''
}));
return {
articles: formattedArticles,
total: formattedArticles.length,
success: true
};
} catch (error) {
return {
articles: [],
success: false,
message: `Failed to get weekly favorites: ${error instanceof Error ? error.message : 'Unknown error'}`
};
}
}
/**
* Get full article content by ID
* @param articleId - Article ID
* @returns Full article with content
*/
async get_article_full(articleId: number): Promise<ContentResponse> {
try {
const articleRepository = AppDataSource.getRepository(Article);
const article = await articleRepository.findOne({
where: { id: articleId },
relations: ['feed']
});
if (!article) {
return {
articles: [],
success: false,
message: `Article ID ${articleId} not found`
};
}
const formattedArticle: ArticleItem = {
id: article.id,
title: article.title,
content: article.content,
link: article.link,
pubDate: article.pubDate.toISOString(),
fetchDate: article.fetchDate.toISOString(),
status: article.status,
feedTitle: article.feed?.title || '',
feedCategory: article.feed?.category || '',
excerpt: this.createExcerpt(article.content)
};
return {
articles: [formattedArticle],
success: true
};
} catch (error) {
return {
articles: [],
success: false,
message: `Failed to get article: ${error instanceof Error ? error.message : 'Unknown error'}`
};
}
}
/**
* Set article status/tag
* @param articleId - Article ID
* @param status - New status
* @returns Operation result
*/
async set_tag(articleId: number, status: ArticleStatus): Promise<TagResponse> {
try {
const articleRepository = AppDataSource.getRepository(Article);
const article = await articleRepository.findOne({
where: { id: articleId }
});
if (!article) {
return {
success: false,
message: `Article ID ${articleId} not found`
};
}
article.status = status;
await articleRepository.save(article);
return {
success: true,
message: `Article ${articleId} status updated to ${status}`
};
} catch (error) {
return {
success: false,
message: `Failed to update article status: ${error instanceof Error ? error.message : 'Unknown error'}`
};
}
}
/**
* Set blog/feed as favorite
* @param feedId - Feed ID to update
* @param isFavorite - Whether to mark as favorite
* @returns Operation result
*/
async set_favorite_blog(feedId: number, isFavorite: boolean): Promise<TagResponse> {
try {
const feedRepository = AppDataSource.getRepository(Feed);
const feed = await feedRepository.findOne({
where: { id: feedId }
});
if (!feed) {
return {
success: false,
message: `Blog/Feed ID ${feedId} not found`
};
}
feed.isFavorite = isFavorite;
await feedRepository.save(feed);
return {
success: true,
message: `Blog '${feed.title}' ${isFavorite ? 'marked as favorite' : 'removed from favorites'}`
};
} catch (error) {
return {
success: false,
message: `Failed to update blog favorite status: ${error instanceof Error ? error.message : 'Unknown error'}`
};
}
}
/**
* Create excerpt from content
* @param content - Full content
* @returns Excerpt (first 200 chars)
*/
private createExcerpt(content: string): string {
const plainText = content.replace(/<[^>]*>/g, '').trim();
return plainText.length > 200
? plainText.substring(0, 200) + '...'
: plainText;
}
}