mcp-llm
by sammcj
- src
#!/usr/bin/env node
/**
* MCP server that provides access to LLMs using the LlamaIndexTS library.
* It implements tools for generating code, documentation, and answering questions.
*/
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import {
CallToolRequestSchema,
ListToolsRequestSchema,
ErrorCode,
McpError
} from "@modelcontextprotocol/sdk/types.js";
import { ChatMessage, MessageContent, ChatResponse } from "llamaindex";
import { OpenAI } from "@llamaindex/openai";
import { Ollama } from "@llamaindex/ollama";
import { Bedrock, BEDROCK_MODELS } from "@llamaindex/community";
import fs from "fs";
import path from "path";
/**
* Environment variable configuration for the LLM
*/
interface LLMConfig {
modelName: string;
modelProvider: string;
baseURL?: string;
temperature?: number;
numCtx?: number;
topP?: number;
topK?: number;
minP?: number;
repetitionPenalty?: number;
systemPromptGenerateCode?: string;
systemPromptGenerateDocumentation?: string;
systemPromptAskQuestion?: string;
timeoutS?: number;
allowFileWrite?: boolean;
}
/**
* Get configuration from environment variables
*/
function getConfig(): LLMConfig {
return {
modelName: process.env.LLM_MODEL_NAME || "",
modelProvider: process.env.LLM_MODEL_PROVIDER || "",
baseURL: process.env.LLM_BASE_URL,
temperature: process.env.LLM_TEMPERATURE ? parseFloat(process.env.LLM_TEMPERATURE) : undefined,
numCtx: process.env.LLM_NUM_CTX ? parseInt(process.env.LLM_NUM_CTX) : undefined,
topP: process.env.LLM_TOP_P ? parseFloat(process.env.LLM_TOP_P) : undefined,
topK: process.env.LLM_TOP_K ? parseInt(process.env.LLM_TOP_K) : undefined,
minP: process.env.LLM_MIN_P ? parseFloat(process.env.LLM_MIN_P) : undefined,
repetitionPenalty: process.env.LLM_REPETITION_PENALTY ? parseFloat(process.env.LLM_REPETITION_PENALTY) : undefined,
systemPromptGenerateCode: process.env.LLM_SYSTEM_PROMPT_GENERATE_CODE,
systemPromptGenerateDocumentation: process.env.LLM_SYSTEM_PROMPT_GENERATE_DOCUMENTATION,
systemPromptAskQuestion: process.env.LLM_SYSTEM_PROMPT_ASK_QUESTION,
timeoutS: process.env.LLM_TIMEOUT_S ? parseInt(process.env.LLM_TIMEOUT_S) : undefined,
allowFileWrite: process.env.LLM_ALLOW_FILE_WRITE ? process.env.LLM_ALLOW_FILE_WRITE.toLowerCase() === 'true' : false
};
}
/**
* Create an LLM instance based on the configuration
*/
function createLLM(config: LLMConfig, systemPrompt?: string) {
const { modelName, modelProvider, baseURL, ...inferenceParams } = config;
if (!modelName) {
throw new McpError(ErrorCode.InternalError, "Model name is required");
}
if (!modelProvider) {
throw new McpError(ErrorCode.InternalError, "Model provider is required");
}
// Filter out undefined values from inferenceParams
const filteredParams: Record<string, any> = {};
Object.entries(inferenceParams).forEach(([key, value]) => {
if (value !== undefined && !key.startsWith("systemPrompt") && key !== "allowFileWrite") {
filteredParams[key] = value;
}
});
switch (modelProvider.toLowerCase()) {
case "bedrock":
return new Bedrock({
model: modelName,
...(systemPrompt ? { systemPrompt } : {}),
...filteredParams
});
case "ollama":
// Log the configuration
console.error(`Creating Ollama instance with model: "${modelName}"`);
console.error(`Using Ollama host: "${baseURL || "http://localhost:11434"}"`);
console.error(`System prompt: ${systemPrompt ? `"${systemPrompt}"` : "none"}`);
console.error(`Additional parameters:`, filteredParams);
try {
// Create Ollama instance with model name and config
const ollama = new Ollama({
model: modelName,
config: {
host: baseURL || "http://localhost:11434"
},
...(systemPrompt ? { systemPrompt } : {}),
...filteredParams
});
console.error(`Ollama instance created successfully`);
return ollama;
} catch (error) {
console.error(`Error creating Ollama instance:`, error);
throw error;
}
case "openai":
case "openai-compatible":
return new OpenAI({
model: modelName,
apiKey: process.env.OPENAI_API_KEY || "dummy-key",
...(baseURL ? { baseUrl: baseURL } : {}),
...(systemPrompt ? { systemPrompt } : {}),
...filteredParams
});
default:
throw new McpError(
ErrorCode.InternalError,
`Unsupported model provider: ${modelProvider}`
);
}
}
/**
* Main class for the LlamaIndex MCP server
*/
class LlamaIndexServer {
private server: Server;
private config: LLMConfig;
constructor() {
this.config = getConfig();
// Validate required configuration
if (!this.config.modelName) {
console.error("Error: LLM_MODEL_NAME environment variable is required");
process.exit(1);
}
if (!this.config.modelProvider) {
console.error("Error: LLM_MODEL_PROVIDER environment variable is required");
process.exit(1);
}
this.server = new Server(
{
name: "mcp-llm",
version: "0.1.0",
},
{
capabilities: {
tools: {},
},
}
);
this.setupToolHandlers();
}
/**
* Set up the tool handlers for the MCP server
*/
private setupToolHandlers() {
this.server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: [
{
name: "generate_code",
description: "Generate code based on a description",
inputSchema: {
type: "object",
properties: {
description: {
type: "string",
description: "Description of the code to generate",
},
language: {
type: "string",
description: "Programming language (e.g., JavaScript, Python, TypeScript)",
},
additionalContext: {
type: "string",
description: "Additional context or requirements for the code",
},
},
required: ["description"],
},
},
{
name: "generate_code_to_file",
description: "Generate code and write it directly to a file at a specific line number",
inputSchema: {
type: "object",
properties: {
description: {
type: "string",
description: "Description of the code to generate",
},
language: {
type: "string",
description: "Programming language (e.g., JavaScript, Python, TypeScript)",
},
additionalContext: {
type: "string",
description: "Additional context or requirements for the code",
},
filePath: {
type: "string",
description: "Path to the file where the code should be written",
},
lineNumber: {
type: "number",
description: "Line number where the code should be inserted (0-based)",
},
replaceLines: {
type: "number",
description: "Number of lines to replace (0 for insertion only)",
},
},
required: ["description", "filePath", "lineNumber"],
},
},
{
name: "generate_documentation",
description: "Generate documentation for code",
inputSchema: {
type: "object",
properties: {
code: {
type: "string",
description: "Code to document",
},
language: {
type: "string",
description: "Programming language of the code",
},
format: {
type: "string",
description: "Documentation format (e.g., JSDoc, Markdown)",
},
},
required: ["code"],
},
},
{
name: "ask_question",
description: "Ask a question to the LLM",
inputSchema: {
type: "object",
properties: {
question: {
type: "string",
description: "Question to ask",
},
context: {
type: "string",
description: "Additional context for the question",
},
},
required: ["question"],
},
},
],
}));
this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
try {
switch (request.params.name) {
case "generate_code":
return await this.handleGenerateCode(request.params.arguments);
case "generate_code_to_file":
return await this.handleGenerateCodeToFile(request.params.arguments);
case "generate_documentation":
return await this.handleGenerateDocumentation(request.params.arguments);
case "ask_question":
return await this.handleAskQuestion(request.params.arguments);
default:
throw new McpError(
ErrorCode.MethodNotFound,
`Unknown tool: ${request.params.name}`
);
}
} catch (error) {
console.error("Error handling tool call:", error);
if (error instanceof McpError) {
throw error;
}
throw new McpError(
ErrorCode.InternalError,
`Error: ${error instanceof Error ? error.message : String(error)}`
);
}
});
}
/**
* Handle the generate_code tool
*/
private async handleGenerateCode(args: any): Promise<any> {
if (!args?.description) {
throw new McpError(ErrorCode.InvalidParams, "Description is required");
}
const language = args.language || "JavaScript";
const additionalContext = args.additionalContext || "";
try {
const llm = createLLM(this.config, this.config.systemPromptGenerateCode);
const messages: ChatMessage[] = [
{
role: "user",
content: `Generate ${language} code for the following description:\n\n${args.description}\n\n${additionalContext ? `Additional context:\n${additionalContext}` : ""}`,
},
];
const response = await llm.chat({ messages });
return {
content: [
{
type: "text",
text: response.message.content,
},
],
};
} catch (error) {
console.error("Error in handleGenerateCode:", error);
// Return a helpful error message
return {
content: [
{
type: "text",
text: `Error generating code: ${error instanceof Error ? error.message : String(error)}\n\nPlease check that the model "${this.config.modelName}" is available on your Ollama server at "${this.config.baseURL || 'http://localhost:11434'}".\n\nYou may need to pull the model first using: ollama pull ${this.config.modelName}`,
},
],
isError: true
};
}
}
/**
* Handle the generate_documentation tool
*/
private async handleGenerateDocumentation(args: any): Promise<any> {
if (!args?.code) {
throw new McpError(ErrorCode.InvalidParams, "Code is required");
}
const language = args.language || "JavaScript";
const format = args.format || "Markdown";
try {
// Check if the model name might have a suffix
let modelName = this.config.modelName;
// Try with the original model name
try {
console.error(`Attempting to use model: "${modelName}"`);
const llm = createLLM({...this.config, modelName}, this.config.systemPromptGenerateDocumentation);
const messages: ChatMessage[] = [
{
role: "user",
content: `Generate ${format} documentation for the following ${language} code:\n\n\`\`\`${language}\n${args.code}\n\`\`\``,
},
];
const response = await llm.chat({ messages });
return {
content: [
{
type: "text",
text: response.message.content,
},
],
};
} catch (originalError) {
console.error(`Error with original model name "${modelName}":`, originalError);
// If the model name contains a suffix, try without it
if (modelName.includes("_")) {
const baseModelName = modelName.split("_")[0];
console.error(`Trying with base model name: "${baseModelName}"`);
try {
const llm = createLLM({...this.config, modelName: baseModelName}, this.config.systemPromptGenerateDocumentation);
const messages: ChatMessage[] = [
{
role: "user",
content: `Generate ${format} documentation for the following ${language} code:\n\n\`\`\`${language}\n${args.code}\n\`\`\``,
},
];
const response = await llm.chat({ messages });
return {
content: [
{
type: "text",
text: response.message.content,
},
],
};
} catch (baseModelError) {
console.error(`Error with base model name "${baseModelName}":`, baseModelError);
throw originalError; // Throw the original error
}
} else {
throw originalError;
}
}
} catch (error) {
console.error("Error in handleGenerateDocumentation:", error);
// Return a helpful error message
return {
content: [
{
type: "text",
text: `Error generating documentation: ${error instanceof Error ? error.message : String(error)}\n\nPlease check that the model "${this.config.modelName}" is available on your Ollama server at "${this.config.baseURL || 'http://localhost:11434'}".\n\nYou may need to pull the model first using: ollama pull ${this.config.modelName}`,
},
],
isError: true
};
}
}
/**
* Handle the ask_question tool
*/
private async handleAskQuestion(args: any): Promise<any> {
if (!args?.question) {
throw new McpError(ErrorCode.InvalidParams, "Question is required");
}
const context = args.context || "";
try {
const llm = createLLM(this.config, this.config.systemPromptAskQuestion);
const messages: ChatMessage[] = [
{
role: "user",
content: `${args.question}${context ? `\n\nContext:\n${context}` : ""}`,
},
];
const response = await llm.chat({ messages });
return {
content: [
{
type: "text",
text: response.message.content,
},
],
};
} catch (error) {
console.error("Error in handleAskQuestion:", error);
// Return a helpful error message
return {
content: [
{
type: "text",
text: `Error answering question: ${error instanceof Error ? error.message : String(error)}\n\nPlease check that the model "${this.config.modelName}" is available on your Ollama server at "${this.config.baseURL || 'http://localhost:11434'}".\n\nYou may need to pull the model first using: ollama pull ${this.config.modelName}`,
},
],
isError: true
};
}
}
/**
* Handle the generate_code_to_file tool
*/
private async handleGenerateCodeToFile(args: any): Promise<any> {
if (!args?.description) {
throw new McpError(ErrorCode.InvalidParams, "Description is required");
}
if (!args?.filePath) {
throw new McpError(ErrorCode.InvalidParams, "File path is required");
}
if (args.lineNumber === undefined || args.lineNumber === null) {
throw new McpError(ErrorCode.InvalidParams, "Line number is required");
}
// Check if file writing is allowed
if (!this.config.allowFileWrite) {
return {
content: [
{
type: "text",
text: `Error: File writing is not allowed. Set LLM_ALLOW_FILE_WRITE=true in your environment variables to enable this feature.`,
},
],
isError: true
};
}
const language = args.language || "JavaScript";
const additionalContext = args.additionalContext || "";
// Log original path and current working directory
const originalPath = args.filePath
const cwd = process.cwd()
console.error(`Original file path: ${originalPath}`)
console.error(`Current working directory: ${cwd}`);
// Ensure we have a fully qualified file path
const filePath = path.isAbsolute(originalPath) ? originalPath : path.resolve(cwd, originalPath)
console.error(`Resolved file path: ${filePath}`)
// Check if the file and directory exist and are writable
const directory = path.dirname(filePath)
console.error(`Directory: ${directory}`)
console.error(`Directory exists: ${fs.existsSync(directory)}`)
if (fs.existsSync(filePath)) {
console.error(`File exists: true`)
try {
fs.accessSync(filePath, fs.constants.W_OK)
console.error(`File is writable: true`)
} catch (error) {
console.error(`File is writable: false - ${error instanceof Error ? error.message : String(error)}`)
}
} else {
console.error(`File exists: false`)
try {
fs.accessSync(directory, fs.constants.W_OK)
console.error(`Directory is writable: true`)
} catch (error) {
console.error(`Directory is writable: false - ${error instanceof Error ? error.message : String(error)}`)
}
}
const lineNumber = parseInt(args.lineNumber, 10);
const replaceLines = args.replaceLines ? parseInt(args.replaceLines, 10) : 0;
try {
// Generate code using the LLM
const llm = createLLM(this.config, this.config.systemPromptGenerateCode);
const messages: ChatMessage[] = [
{
role: "user",
content: `Generate ${language} code for the following description:\n\n${args.description}\n\n${additionalContext ? `Additional context:\n${additionalContext}` : ""}`,
},
];
console.error(`Generating code for file: ${filePath} at line: ${lineNumber}`);
const response = await llm.chat({ messages });
// Convert the message content to a string
let generatedCode = "";
if (typeof response.message.content === "string") {
generatedCode = response.message.content;
} else if (Array.isArray(response.message.content)) {
// If it's an array of MessageContentDetail, concatenate text parts
generatedCode = response.message.content
.filter(item => item.type === "text")
.map(item => item.text)
.join("\n");
}
// Extract code from markdown code blocks if present
let codeToInsert = generatedCode;
const codeBlockRegex = /```(?:\w+)?\n([\s\S]+?)\n```/;
const match = codeBlockRegex.exec(generatedCode);
if (match && match[1]) {
codeToInsert = match[1];
}
// Make sure the directory exists
const directory = path.dirname(filePath);
if (!fs.existsSync(directory)) {
fs.mkdirSync(directory, { recursive: true });
}
// Read the file if it exists, or create an empty file
let fileContent = "";
try {
fileContent = fs.existsSync(filePath) ? fs.readFileSync(filePath, "utf-8") : "";
} catch (error) {
console.error(`Error reading file ${filePath}:`, error);
return {
content: [
{
type: "text",
text: `Error reading file: ${error instanceof Error ? error.message : String(error)}`,
},
],
isError: true
};
}
// Split the file content into lines
const lines = fileContent.split("\n");
// Ensure lineNumber is within bounds
const safeLineNumber = Math.max(0, Math.min(lineNumber, lines.length));
// Split the generated code into lines
const codeLines = codeToInsert.split("\n");
// Remove lines to be replaced if specified
if (replaceLines > 0) {
lines.splice(safeLineNumber, replaceLines, ...codeLines);
} else {
// Insert the generated code at the specified line
lines.splice(safeLineNumber, 0, ...codeLines);
}
// Join the lines back together
const updatedContent = lines.join("\n");
// Write the updated content back to the file
try {
fs.writeFileSync(filePath, updatedContent, "utf-8");
} catch (error) {
console.error(`Error writing to file ${filePath}:`, error);
return {
content: [
{
type: "text",
text: `Error writing to file: ${error instanceof Error ? error.message : String(error)}`,
},
],
isError: true
};
}
return {
content: [
{
type: "text",
text: `Successfully generated and inserted code into ${filePath} at line ${safeLineNumber}.\n\nGenerated code:\n\`\`\`${language}\n${codeToInsert}\n\`\`\``,
},
],
};
} catch (error) {
console.error("Error in handleGenerateCodeToFile:", error);
// Return a helpful error message
return {
content: [
{
type: "text",
text: `Error generating code to file: ${error instanceof Error ? error.message : String(error)}\n\nPlease check that the model "${this.config.modelName}" is available on your Ollama server at "${this.config.baseURL || 'http://localhost:11434'}".\n\nYou may need to pull the model first using: ollama pull ${this.config.modelName}`,
},
],
isError: true
};
}
}
/**
* Start the server
*/
async run() {
const transport = new StdioServerTransport();
await this.server.connect(transport);
console.error("LlamaIndex MCP server running on stdio");
}
}
// Start the server
const server = new LlamaIndexServer();
server.run().catch((error) => {
console.error("Server error:", error);
process.exit(1);
});