@mettamatt/code-reasoning
Version:
Enhanced MCP server for code reasoning using sequential thinking methodology, optimized for programming tasks
382 lines (381 loc) • 16 kB
JavaScript
/**
* @fileoverview Prompt manager for MCP prompts.
*
* This class handles the management of prompts, including registration,
* validation, and application of prompt templates.
*
* It implements the standard MCP CompleteRequestSchema protocol for
* providing auto-completion of prompt arguments with previously stored values.
*/
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { z } from 'zod';
import { CODE_REASONING_PROMPTS, PROMPT_TEMPLATES } from './templates.js';
import { PromptValueManager } from './valueManager.js';
import { CONFIG_DIR } from '../utils/config.js';
// Constants for validation and sanitization
const MAX_STRING_LENGTH = 5000;
const MAX_CODE_LENGTH = 20000;
const MAX_NAME_LENGTH = 100;
const MAX_DESCRIPTION_LENGTH = 1000;
const MAX_TEMPLATE_LENGTH = 10000;
/**
* Manages prompt templates and their operations.
* Uses the CompleteRequestSchema MCP protocol for argument completion.
*/
export class PromptManager {
prompts;
templates;
valueManager;
// Zod schemas for input sanitization
baseStringSchema = z
.string()
.max(MAX_STRING_LENGTH, `Input exceeds maximum length of ${MAX_STRING_LENGTH} characters`)
.transform(val => (val ? val.trim() : ''))
.transform(val => {
// Escape HTML/Markdown special characters
return val
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
})
.transform(val => {
// Neutralize template injection attempts
return val.replace(/\{([^}]+)\}/g, '[$1]');
})
.transform(val => {
// Remove potentially dangerous patterns (credit card numbers, private keys, etc.)
return val
.replace(/\b(?:\d[ -]*?){13,16}\b/g, '[REDACTED]') // Credit cards
.replace(/-----BEGIN [A-Z ]+ PRIVATE KEY-----[\s\S]*?-----END [A-Z ]+ PRIVATE KEY-----/g, '[REDACTED KEY]'); // Private keys
});
codeSchema = z
.string()
.max(MAX_CODE_LENGTH, `Code exceeds maximum length of ${MAX_CODE_LENGTH} characters`)
.transform(val => (val ? val.trim() : ''));
workingDirectorySchema = z
.string()
.max(MAX_STRING_LENGTH, `Working directory path exceeds maximum length of ${MAX_STRING_LENGTH} characters`)
.transform(val => (val ? val.trim() : ''))
.transform(val => {
// For working directory, we can keep path separators and basic structure
// but sanitize to prevent potential path traversal or command injection
return val
.replace(/\.\./g, '') // Remove path traversal sequences
.replace(/[;&|`$]/g, ''); // Remove shell command operators
});
// Schema for PromptArgument with added validation
PromptArgumentSchema = z.object({
name: z
.string()
.min(1)
.max(MAX_NAME_LENGTH)
.regex(/^[a-zA-Z0-9_]+$/, 'Argument name must contain only alphanumeric characters and underscores'),
description: z.string().min(1).max(MAX_DESCRIPTION_LENGTH),
required: z.boolean(), // Removed .strict() as it's not available in this Zod version
});
// Schema for the entire prompt data with template sanitization
PromptDataSchema = z
.object({
name: z
.string()
.min(1)
.max(MAX_NAME_LENGTH)
.regex(/^[a-zA-Z0-9_-]+$/, 'Prompt name must contain only alphanumeric characters, underscores, and hyphens'),
description: z.string().min(1).max(MAX_DESCRIPTION_LENGTH),
template: z
.string()
.min(1)
.max(MAX_TEMPLATE_LENGTH)
.transform(val => {
// Basic template sanitization - could be expanded
return val.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '');
}),
arguments: z.array(this.PromptArgumentSchema).optional().default([]),
})
.strict(); // Ensure no unknown properties
/**
* Creates a new PromptManager instance with default code reasoning prompts.
*
* @param configDir Optional directory for configuration files. Defaults to the centralized CONFIG_DIR.
*/
constructor(configDir) {
this.prompts = { ...CODE_REASONING_PROMPTS };
this.templates = { ...PROMPT_TEMPLATES };
// Use provided config directory or default to CONFIG_DIR
const resolvedConfigDir = configDir || CONFIG_DIR;
// Create main config directory if it doesn't exist
if (!fs.existsSync(resolvedConfigDir)) {
try {
fs.mkdirSync(resolvedConfigDir, { recursive: true });
console.error(`Created main config directory: ${resolvedConfigDir}`);
}
catch (err) {
console.error(`Failed to create main config directory: ${resolvedConfigDir}`, err);
}
}
// Create prompts subdirectory if it doesn't exist
const promptsDir = path.join(resolvedConfigDir, 'prompts');
if (!fs.existsSync(promptsDir)) {
try {
fs.mkdirSync(promptsDir, { recursive: true });
console.error(`Created prompts directory: ${promptsDir}`);
}
catch (err) {
console.error(`Failed to create prompts directory: ${promptsDir}`, err);
}
}
console.error(`Using config directory: ${resolvedConfigDir}`);
try {
this.valueManager = new PromptValueManager(resolvedConfigDir);
}
catch (err) {
console.error(`Error initializing PromptValueManager: ${err}`);
// Create a dummy value manager that doesn't actually save anything
this.valueManager = new PromptValueManager(os.tmpdir());
}
console.error('PromptManager initialized with', Object.keys(this.prompts).length, 'prompts');
}
/**
* Registers a new prompt and its template function.
*
* @param prompt The prompt definition
* @param template The template function that applies arguments to generate a result
*/
registerPrompt(prompt, template) {
this.prompts[prompt.name] = prompt;
this.templates[prompt.name] = template;
console.error(`Registered prompt: ${prompt.name}`);
}
/**
* Gets all available prompts.
*
* Note: Previously stored values for prompt arguments are provided through
* the CompleteRequestSchema MCP protocol, not through the prompt objects.
*
* @returns An array of all registered prompts
*/
getAllPrompts() {
// Simply return the prompts without adding defaultValues
return Object.values(this.prompts);
}
/**
* Gets a specific prompt by name.
*
* @param name The name of the prompt to retrieve
* @returns The prompt or undefined if not found
*/
getPrompt(name) {
return this.prompts[name];
}
/**
* Gets stored values for a specific prompt.
* This method is used by the CompleteRequestSchema handler to provide
* auto-completion of prompt arguments.
*
* @param name The name of the prompt
* @returns The stored values for the prompt
*/
getStoredValues(name) {
return this.valueManager.getStoredValues(name);
}
/**
* Merges provided arguments with stored values, with provided args taking precedence.
* This is a helper method to simplify the applyPrompt method.
*
* @param promptName The name of the prompt
* @param args The provided arguments
* @returns The merged arguments
*/
mergeWithStoredValues(promptName, args) {
// Get stored values
const storedValues = this.getStoredValues(promptName);
// Filter out empty args
const filteredArgs = {};
Object.entries(args).forEach(([key, value]) => {
if (value.trim() !== '') {
filteredArgs[key] = value;
}
});
// Merge stored values with filtered args (filtered args take precedence)
return { ...storedValues, ...filteredArgs };
}
/**
* Applies a prompt with the given arguments.
* Merges provided arguments with previously stored values,
* with provided arguments taking precedence.
*
* @param name The name of the prompt to apply
* @param args The arguments to apply to the prompt template
* @returns The result of applying the prompt
* @throws Error if the prompt doesn't exist or arguments are invalid
*/
applyPrompt(name, args = {}) {
const prompt = this.getPrompt(name);
if (!prompt) {
throw new Error(`Prompt not found: ${name}`);
}
// Merge with stored values
const mergedArgs = this.mergeWithStoredValues(name, args);
// Validate arguments
const validationErrors = this.validatePromptArguments(prompt, mergedArgs);
if (validationErrors.length > 0) {
throw new Error(`Validation errors:\n${validationErrors.join('\n')}`);
}
// Get the template function
const templateFn = this.templates[name];
if (!templateFn) {
throw new Error(`Template implementation not found for prompt: ${name}`);
}
// Update stored values with the new ones
this.valueManager.updateStoredValues(name, mergedArgs);
// Apply the template with merged args
return templateFn(mergedArgs);
}
/**
* Loads custom prompts from JSON files in a directory.
*
* @param directory The directory containing JSON prompt files
*/
async loadCustomPrompts(directory) {
try {
if (!fs.existsSync(directory)) {
try {
fs.mkdirSync(directory, { recursive: true });
console.error(`Created custom prompts directory: ${directory}`);
}
catch (err) {
console.error(`Failed to create custom prompts directory: ${directory}`, err);
return;
}
}
const files = fs.readdirSync(directory);
console.error(`Found ${files.length} files in custom prompts directory`);
for (const file of files) {
if (file.endsWith('.json')) {
try {
const filePath = path.join(directory, file);
const content = fs.readFileSync(filePath, 'utf8');
// Parse JSON and validate with Zod schema
const promptDataResult = this.PromptDataSchema.safeParse(JSON.parse(content));
if (!promptDataResult.success) {
console.error(`Invalid prompt in file ${file}:`, promptDataResult.error.issues
.map(i => `${i.path.join('.')}: ${i.message}`)
.join(', '));
continue;
}
// Extract validated data
const promptData = promptDataResult.data;
// Register the prompt with validated data
this.registerPrompt({
name: promptData.name,
description: promptData.description,
arguments: promptData.arguments.map(arg => ({
name: String(arg.name),
description: String(arg.description),
required: Boolean(arg.required),
})),
}, args => ({
messages: [
{
role: 'user',
content: {
type: 'text',
text: this.applyTemplate(promptData.template, args, promptData.name),
},
},
],
}));
console.error(`Loaded custom prompt: ${promptData.name}`);
}
catch (err) {
console.error(`Error loading prompt from ${file}:`, err);
}
}
}
}
catch (err) {
console.error(`Error loading custom prompts:`, err);
}
}
/**
* Gets the appropriate schema for a given argument.
*
* @param argName The name of the argument
* @param promptName The name of the prompt
* @returns A Zod schema for validating and sanitizing the argument
*/
getSchemaForArg(argName, promptName) {
// Choose schema based on arg name and context (prompt name)
if (argName === 'code_path' || argName === 'language') {
return this.codeSchema;
}
else if (argName === 'working_directory') {
return this.workingDirectorySchema;
}
else if (promptName === 'bug-analysis' && argName === 'bug_behavior') {
// For bug reports, we want to preserve more formatting
return this.codeSchema;
}
// Use default string schema for all other args
return this.baseStringSchema;
}
/**
* Validates prompt arguments against the prompt definition.
*
* @param prompt The prompt to validate against
* @param args The arguments to validate
* @returns Array of validation error messages, empty if valid
*/
validatePromptArguments(prompt, args) {
const errors = [];
// Check for required arguments
(prompt.arguments || []).forEach((arg) => {
if (arg.required && (!args[arg.name] || args[arg.name].trim() === '')) {
errors.push(`Missing required argument: ${arg.name} (${arg.description})`);
}
});
// Check for unknown arguments
const validArgNames = new Set((prompt.arguments || []).map(arg => arg.name));
Object.keys(args).forEach(argName => {
if (!validArgNames.has(argName)) {
errors.push(`Unknown argument: ${argName}`);
}
});
return errors;
}
/**
* Applies a template string with argument values.
* Sanitizes input values to prevent template injection and other security issues.
*
* @param template The template string
* @param args The argument values to apply
* @param promptName The name of the prompt (for context-aware sanitization)
* @returns The template with arguments applied
*/
applyTemplate(template, args, promptName = '') {
let result = template;
// Replace {arg_name} with sanitized values
Object.entries(args).forEach(([key, value]) => {
// Get the appropriate schema for this argument
const schema = this.getSchemaForArg(key, promptName);
// Parse and transform the value (sanitize)
const sanitizeResult = schema.safeParse(value || '');
// Apply replacement
const regex = new RegExp(`\\{${key}\\}`, 'g');
if (sanitizeResult.success) {
result = result.replace(regex, sanitizeResult.data);
}
else {
// Log validation errors with context for debugging
console.error(`Validation failed for argument '${key}' in prompt '${promptName}':`, sanitizeResult.error.issues.map(i => `${i.path}: ${i.message}`).join(', '));
// Fallback to empty string or safe default
result = result.replace(regex, '');
}
});
return result;
}
}