MCP Expert Server
- mcp-expert-server
- src
- services
import Anthropic from '@anthropic-ai/sdk';
import { config } from 'dotenv';
import { readFileSync, readdirSync } from 'fs';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
config();
const CLAUDE_API_KEY = process.env.ANTHROPIC_API_KEY;
if (!CLAUDE_API_KEY) {
console.error('Error: The ANTHROPIC_API_KEY environment variable is missing or not set.');
throw new Error('ANTHROPIC_API_KEY environment variable is required for the Expert Service to function.');
}
/**
* Interface for Claude message content
*/
interface ClaudeContent {
type: 'text' | 'image';
text?: string;
source?: {
type: 'base64' | 'url';
media_type: string;
data: string;
};
}
/**
* Interface for Claude message
*/
interface ClaudeMessage {
role: 'user' | 'assistant';
content: string | Array<ClaudeContent>;
}
/**
* Interface for Claude API response
*/
interface ClaudeResponse {
id: string;
type: 'message';
role: 'assistant';
model: string;
content: Array<ClaudeContent>;
stop_reason: 'end_turn' | 'max_tokens' | 'stop_sequence';
stop_sequence?: string;
usage: {
input_tokens: number;
output_tokens: number;
};
}
/**
* Configuration options for ExpertService
*/
interface ExpertServiceConfig {
apiKey?: string;
model?: string;
maxTokens?: number;
docsDir?: string;
promptsDir?: string;
}
// Add a debug logging function that only writes to stderr
function debugLog(message: string) {
console.error(`[DEBUG] ${message}`);
}
/**
* Service for handling documentation queries and generation using Claude
*/
export class ExpertService {
private anthropic: Anthropic;
private documentation: Map<string, string>;
private systemPrompt: string;
private toolMetadata: string = '';
private queryMetadata: string = '';
private readonly model: string;
private readonly maxTokens: number;
private readonly docsDir: string;
private readonly promptsDir: string;
private serviceDescription: string = '';
/**
* Creates a new instance of ExpertService
* @param config - Optional configuration parameters
*/
constructor(config?: ExpertServiceConfig) {
this.anthropic = new Anthropic({
apiKey: config?.apiKey || CLAUDE_API_KEY,
});
this.documentation = new Map();
this.model = config?.model || 'claude-3-sonnet-20240229';
this.maxTokens = config?.maxTokens || 1500;
// Use paths relative to script location if not provided in config
const scriptDir = dirname(fileURLToPath(import.meta.url));
const baseDir = join(scriptDir, '..', '..');
this.docsDir = config?.docsDir || join(baseDir, 'docs');
this.promptsDir = config?.promptsDir || join(baseDir, 'prompts');
debugLog(`Using docs directory: ${this.docsDir}`);
debugLog(`Using prompts directory: ${this.promptsDir}`);
// Load files synchronously
debugLog('Loading documentation and configuration files...');
this.loadDocumentation();
this.systemPrompt = this.loadSystemPrompt();
this.toolMetadata = this.loadToolMetadata();
this.queryMetadata = this.loadQueryMetadata();
this.serviceDescription = this.loadServiceDescription();
debugLog('Initial file loading complete');
// Validate initialization
if (!this.systemPrompt) {
debugLog('Warning: System prompt could not be loaded. Service may not function as expected.');
}
if (this.documentation.size === 0) {
debugLog('Warning: No documentation files were loaded. Service may not function as expected.');
}
}
/**
* Loads the system prompt from file
* @returns The system prompt string or empty string if not found
*/
private loadSystemPrompt(): string {
const promptPath = join(this.promptsDir, 'system-prompt.txt');
try {
return readFileSync(promptPath, 'utf-8');
} catch (error) {
debugLog(`Failed to load system prompt from ${promptPath}: ${error}`);
return '';
}
}
/**
* Loads all documentation files from the docs directory
*/
private loadDocumentation(): void {
try {
const files = readdirSync(this.docsDir)
.filter(file => {
// Filter out system files and non-text files
return !file.startsWith('.') &&
(file.endsWith('.txt') || file.endsWith('.md') || file.endsWith('.json'));
});
if (files.length === 0) {
debugLog(`No valid documentation files found in ${this.docsDir}`);
return;
}
for (const file of files) {
try {
const filePath = join(this.docsDir, file);
const content = readFileSync(filePath, 'utf-8');
this.documentation.set(file, content);
debugLog(`Successfully loaded documentation from ${file}`);
} catch (error) {
debugLog(`Failed to load documentation file ${file}: ${error}`);
}
}
} catch (error) {
debugLog(`Failed to read docs directory ${this.docsDir}: ${error}`);
}
}
/**
* Loads tool description metadata from file
*/
private loadToolMetadata(): string {
const metadataPath = join(this.promptsDir, 'tool-metadata.txt');
try {
const metadata = readFileSync(metadataPath, 'utf-8');
debugLog('Successfully loaded tool metadata file');
return metadata;
} catch (error) {
debugLog('No tool metadata file found - this is optional and can be added to provide additional context for tool descriptions');
return '';
}
}
/**
* Loads query context metadata from file
*/
private loadQueryMetadata(): string {
const metadataPath = join(this.promptsDir, 'query-metadata.txt');
try {
const metadata = readFileSync(metadataPath, 'utf-8');
debugLog('Successfully loaded query metadata file');
return metadata;
} catch (error) {
debugLog('No query metadata file found - this is optional and can be added to provide additional context for queries');
return '';
}
}
/**
* Loads the pre-generated service description
* @returns The service description or empty string if not found
*/
private loadServiceDescription(): string {
const descriptionPath = join(this.promptsDir, 'service-description.txt');
try {
const description = readFileSync(descriptionPath, 'utf-8');
debugLog('Successfully loaded service description');
return description;
} catch (error) {
debugLog('No service description found - please run setup script first');
return '';
}
}
/**
* Combines all loaded documentation into a single string
* @returns Combined documentation string
*/
getAllDocumentation(): string {
if (this.documentation.size === 0) {
debugLog('No documentation files are currently loaded');
return '';
}
return Array.from(this.documentation.values()).join('\n\n');
}
/**
* Validates and extracts text content from Claude's response
* @param response - The response from Claude
* @param context - Context for error messages
* @returns The extracted text or an error message
*/
private validateClaudeResponse(response: ClaudeResponse, context: string): string {
if (!response?.content?.length) {
debugLog(`Empty response from Claude for ${context}`);
return `Error: Unable to process ${context}`;
}
const content = response.content[0];
if (content.type !== 'text' || !content.text) {
debugLog(`Unexpected response type from Claude for ${context}`);
return `Error: Unable to process ${context}`;
}
return content.text;
}
private async callClaude(params: Parameters<typeof this.anthropic.messages.create>[0], context: string): Promise<ClaudeResponse> {
const timeout = 30000; // 30 seconds timeout
const timeoutPromise = new Promise<never>((_, reject) => {
setTimeout(() => reject(new Error(`Request timed out after ${timeout}ms`)), timeout);
});
try {
debugLog(`Making API request to Claude for ${context}...`);
const response = await Promise.race([
this.anthropic.messages.create(params),
timeoutPromise
]);
debugLog('Received response from Claude');
return response as ClaudeResponse;
} catch (error) {
if (error instanceof Error && error.message.includes('timed out')) {
debugLog(`Claude API request timed out for ${context}`);
} else {
debugLog(`Claude API request failed for ${context}: ${error}`);
}
throw error;
}
}
/**
* Generates a query based on natural language request
* @param request - The natural language request
* @returns Generated query or error message
*/
async generateQuery(request: string): Promise<string> {
const startTime = Date.now();
debugLog(`Starting query generation for: "${request}"`);
try {
const message = await this.callClaude({
model: this.model,
max_tokens: this.maxTokens,
system: this.systemPrompt,
messages: [
{
role: 'user',
content: `Given this API documentation:
${this.getAllDocumentation()}
${this.queryMetadata ? `Additional Query Context:\n${this.queryMetadata}\n\n` : ''}
Generate a query for this request: "${request}"
Please return ONLY the query, with no additional explanation or context.`
}
]
}, `query request: "${request}"`);
const response = this.validateClaudeResponse(message, `query request: "${request}"`);
const duration = Date.now() - startTime;
debugLog(`Query generation completed in ${duration}ms`);
return response;
} catch (error) {
const duration = Date.now() - startTime;
debugLog(`Query generation failed after ${duration}ms: ${error}`);
return `Error: Unable to generate query for request: "${request}"`;
}
}
/**
* Gets information from documentation based on a question
* @param request - The documentation question
* @returns Response from documentation or error message
*/
async getDocumentationResponse(request: string): Promise<string> {
const startTime = Date.now();
debugLog(`Starting documentation request for: "${request}"`);
try {
const message = await this.callClaude({
model: this.model,
max_tokens: this.maxTokens,
system: this.systemPrompt,
messages: [
{
role: 'user',
content: `Given this API documentation:
${this.getAllDocumentation()}
${this.queryMetadata ? `Additional Context:\n${this.queryMetadata}\n\n` : ''}
Answer this question about the documentation: "${request}"
Please provide a clear, concise response based solely on the provided documentation and context.`
}
]
}, `documentation request: "${request}"`);
const response = this.validateClaudeResponse(message, `documentation request: "${request}"`);
const duration = Date.now() - startTime;
debugLog(`Documentation request completed in ${duration}ms`);
return response;
} catch (error) {
const duration = Date.now() - startTime;
debugLog(`Documentation request failed after ${duration}ms: ${error}`);
return `Error: Unable to process documentation request: "${request}"`;
}
}
/**
* Analyzes the documentation to generate a service description
* @returns A promise that resolves to the service description
*/
async analyzeDocumentation(): Promise<string> {
// If we already have a description, return it
if (this.serviceDescription) {
return this.serviceDescription;
}
debugLog('Analyzing documentation to generate service description...');
try {
const docs = this.getAllDocumentation();
if (!docs) {
debugLog('No documentation available for analysis');
return '';
}
debugLog(`Analyzing ${this.documentation.size} documentation files...`);
if (this.toolMetadata) {
debugLog('Including tool metadata in analysis');
}
const message = await this.callClaude({
model: this.model,
max_tokens: this.maxTokens,
system: this.systemPrompt,
messages: [
{
role: 'user',
content: `Analyze this API documentation and provide a concise 1-2 sentence description of what this API/service is about and what it does.
Documentation:
${docs}
${this.toolMetadata ? `Additional Context:\n${this.toolMetadata}` : ''}
Your response should be direct and focused on the core functionality, suitable for use in an API tool description.`
}
]
}, 'documentation analysis');
debugLog('Received response from Claude');
const description = this.validateClaudeResponse(message, 'documentation analysis');
if (!description.startsWith('Error:')) {
debugLog('Successfully generated service description: ' + description);
this.serviceDescription = description;
} else {
debugLog('Failed to generate service description: ' + description);
}
return this.serviceDescription;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
debugLog('Error analyzing documentation: ' + errorMessage);
if (error instanceof Error && error.stack) {
debugLog('Stack trace: ' + error.stack);
}
return '';
}
}
/**
* Gets the current service description
* @returns The service description
*/
getServiceDescription(): string {
return this.serviceDescription;
}
/**
* Reloads all documentation and metadata files and updates the service description
* @returns A promise that resolves when the reload is complete
*/
async reloadDocumentation(): Promise<void> {
debugLog('Reloading documentation and metadata...');
// Clear existing documentation
this.documentation.clear();
// Reload all files
this.loadDocumentation();
this.systemPrompt = this.loadSystemPrompt();
this.toolMetadata = this.loadToolMetadata();
this.queryMetadata = this.loadQueryMetadata();
// Update service description
debugLog('Updating service description...');
await this.analyzeDocumentation();
debugLog('Documentation reload complete');
}
/**
* Gets the paths to the documentation and prompts directories
* @returns Object containing the directory paths
*/
getDirectoryPaths(): { docsDir: string; promptsDir: string } {
return {
docsDir: this.docsDir,
promptsDir: this.promptsDir
};
}
}