utils.tsā¢5.93 kB
/**
* Utility functions for the Research MCP
*/
import type { ArxivPaper, SemanticScholarPaper, PubMedPaper, BibTeXEntry, RateLimiter } from './types.js';
/**
* Simple rate limiter implementation
*/
export class SimpleRateLimiter implements RateLimiter {
private requests: number[] = [];
private readonly maxRequests: number;
private readonly windowMs: number;
constructor(maxRequests: number = 3, windowSeconds: number = 1) {
this.maxRequests = maxRequests;
this.windowMs = windowSeconds * 1000;
}
async checkLimit(): Promise<void> {
const now = Date.now();
// Remove requests older than the window
this.requests = this.requests.filter(time => now - time < this.windowMs);
if (this.requests.length >= this.maxRequests) {
const oldestRequest = this.requests[0];
const waitTime = this.windowMs - (now - oldestRequest);
if (waitTime > 0) {
await new Promise<void>(resolve => global.setTimeout(resolve, waitTime));
// Recursively check again after waiting
return this.checkLimit();
}
}
}
incrementCount(): void {
this.requests.push(Date.now());
}
}
/**
* Remove duplicate papers based on title similarity
*/
export function deduplicatePapers<T extends { title: string }>(papers: T[]): T[] {
const seen = new Set<string>();
const unique: T[] = [];
for (const paper of papers) {
const normalizedTitle = normalizeTitle(paper.title);
if (!seen.has(normalizedTitle)) {
seen.add(normalizedTitle);
unique.push(paper);
}
}
return unique;
}
/**
* Normalize title for comparison
*/
function normalizeTitle(title: string): string {
return title
.toLowerCase()
.replace(/[^\w\s]/g, '')
.replace(/\s+/g, ' ')
.trim();
}
/**
* Generate BibTeX entry for arXiv paper
*/
export function arxivToBibTeX(paper: ArxivPaper): string {
const year = new Date(paper.published).getFullYear();
const key = generateBibTeXKey(paper.authors[0] || 'Unknown', year, paper.title);
const authors = paper.authors.join(' and ');
const entry: BibTeXEntry = {
type: 'article',
key,
fields: {
title: `{${paper.title}}`,
author: authors,
year: year.toString(),
eprint: paper.id,
archivePrefix: 'arXiv',
primaryClass: paper.primaryCategory,
url: paper.url,
abstract: `{${paper.abstract}}`
}
};
return formatBibTeX(entry);
}
/**
* Generate BibTeX entry for Semantic Scholar paper
*/
export function semanticScholarToBibTeX(paper: SemanticScholarPaper): string {
const year = paper.year || new Date().getFullYear();
const firstAuthor = paper.authors[0]?.name || 'Unknown';
const key = generateBibTeXKey(firstAuthor, year, paper.title);
const authors = paper.authors.map(a => a.name).join(' and ');
const entry: BibTeXEntry = {
type: 'article',
key,
fields: {
title: `{${paper.title}}`,
author: authors,
year: year.toString(),
url: paper.url
}
};
if (paper.abstract) {
entry.fields.abstract = `{${paper.abstract}}`;
}
if (paper.venue) {
entry.fields.journal = paper.venue;
}
return formatBibTeX(entry);
}
/**
* Generate BibTeX entry for PubMed paper
*/
export function pubmedToBibTeX(paper: PubMedPaper): string {
const key = generateBibTeXKey(paper.authors[0] || 'Unknown', parseInt(paper.year), paper.title);
const authors = paper.authors.join(' and ');
const entry: BibTeXEntry = {
type: 'article',
key,
fields: {
title: `{${paper.title}}`,
author: authors,
year: paper.year,
journal: paper.journal,
url: paper.url,
note: `PMID: ${paper.pmid}`
}
};
if (paper.abstract) {
entry.fields.abstract = `{${paper.abstract}}`;
}
if (paper.doi) {
entry.fields.doi = paper.doi;
}
return formatBibTeX(entry);
}
/**
* Generate BibTeX citation key
*/
function generateBibTeXKey(author: string, year: number, title: string): string {
const authorPart = author.split(' ').pop()?.toLowerCase() || 'unknown';
const titleWords = title.toLowerCase().split(' ').filter(w => w.length > 3);
const titlePart = titleWords[0] || 'paper';
return `${authorPart}${year}${titlePart}`;
}
/**
* Format BibTeX entry to string
*/
function formatBibTeX(entry: BibTeXEntry): string {
let result = `@${entry.type}{${entry.key},\n`;
for (const [key, value] of Object.entries(entry.fields)) {
result += ` ${key} = ${value},\n`;
}
result += '}\n';
return result;
}
/**
* Sanitize user input for API queries
*/
export function sanitizeQuery(query: string): string {
return query.trim().slice(0, 500);
}
/**
* Format error message for MCP response
*/
export function formatError(error: unknown): string {
if (error instanceof Error) {
return error.message;
}
return String(error);
}
/**
* Parse year range from query string
*/
export function parseYearRange(query: string): { startYear?: number; endYear?: number } {
const yearMatch = query.match(/(?:after|since|from)\s+(\d{4})|(?:before|until|to)\s+(\d{4})|(\d{4})-(\d{4})/i);
if (!yearMatch) {
return {};
}
const result: { startYear?: number; endYear?: number } = {};
if (yearMatch[1]) {
result.startYear = parseInt(yearMatch[1]);
}
if (yearMatch[2]) {
result.endYear = parseInt(yearMatch[2]);
}
if (yearMatch[3] && yearMatch[4]) {
result.startYear = parseInt(yearMatch[3]);
result.endYear = parseInt(yearMatch[4]);
}
return result;
}
/**
* Truncate text to specified length
*/
export function truncateText(text: string, maxLength: number = 500): string {
if (text.length <= maxLength) {
return text;
}
return text.slice(0, maxLength - 3) + '...';
}
/**
* Sleep utility for rate limiting
*/
export async function sleep(ms: number): Promise<void> {
await new Promise<void>(resolve => global.setTimeout(resolve, ms));
}