@mickdarling/dollhousemcp
Version:
DollhouseMCP - A Model Context Protocol (MCP) server that enables dynamic AI persona management from markdown files, allowing Claude and other compatible AI assistants to activate and switch between different behavioral personas.
1,126 lines (1,122 loc) ⢠174 kB
JavaScript
#!/usr/bin/env node
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { McpError, ErrorCode } from "@modelcontextprotocol/sdk/types.js";
import * as fs from "fs/promises";
import * as path from "path";
import { loadIndicatorConfig, formatIndicator, validateCustomFormat } from './config/indicator-config.js';
import { SecureYamlParser } from './security/secureYamlParser.js';
import { SecurityError } from './errors/SecurityError.js';
import { APICache } from './cache/APICache.js';
import { validateFilename, sanitizeInput, validateContentSize, validateUsername, validateCategory } from './security/InputValidator.js';
import { SECURITY_LIMITS, VALIDATION_PATTERNS } from './security/constants.js';
import { ContentValidator } from './security/contentValidator.js';
import { generateAnonymousId, generateUniqueId, slugify } from './utils/filesystem.js';
import { PersonaManager } from './persona/PersonaManager.js';
import { GitHubClient, MarketplaceBrowser, MarketplaceSearch, PersonaDetails, PersonaInstaller, PersonaSubmitter } from './marketplace/index.js';
import { UpdateManager } from './update/index.js';
import { ServerSetup } from './server/index.js';
// Default personas that should not be modified in place
const DEFAULT_PERSONAS = [
'business-consultant.md',
'creative-writer.md',
'debug-detective.md',
'eli5-explainer.md',
'technical-analyst.md'
];
export class DollhouseMCPServer {
server;
personasDir;
personas = new Map();
activePersona = null;
currentUser = null;
apiCache = new APICache();
rateLimitTracker = new Map();
indicatorConfig;
personaManager;
githubClient;
marketplaceBrowser;
marketplaceSearch;
personaDetails;
personaInstaller;
personaSubmitter;
updateManager;
serverSetup;
constructor() {
this.server = new Server({
name: "dollhousemcp",
version: "1.0.0",
}, {
capabilities: {
tools: {},
},
});
// Use environment variable if set, otherwise default to personas subdirectory relative to current working directory
this.personasDir = process.env.PERSONAS_DIR || path.join(process.cwd(), "personas");
// Load user identity from environment variables
this.currentUser = process.env.DOLLHOUSE_USER || null;
// Load indicator configuration
this.indicatorConfig = loadIndicatorConfig();
// Initialize persona manager
this.personaManager = new PersonaManager(this.personasDir, this.indicatorConfig);
// Initialize marketplace modules
this.githubClient = new GitHubClient(this.apiCache, this.rateLimitTracker);
this.marketplaceBrowser = new MarketplaceBrowser(this.githubClient);
this.marketplaceSearch = new MarketplaceSearch(this.githubClient);
this.personaDetails = new PersonaDetails(this.githubClient);
this.personaInstaller = new PersonaInstaller(this.githubClient, this.personasDir);
this.personaSubmitter = new PersonaSubmitter();
// Initialize update manager
this.updateManager = new UpdateManager();
// Initialize server setup
this.serverSetup = new ServerSetup();
this.serverSetup.setupServer(this.server, this);
this.loadPersonas();
}
// Tool handler methods - now public for access from tool modules
getPersonaIndicator() {
if (!this.activePersona) {
return "";
}
const persona = this.personas.get(this.activePersona);
if (!persona) {
return "";
}
return formatIndicator(this.indicatorConfig, {
name: persona.metadata.name,
version: persona.metadata.version,
author: persona.metadata.author,
category: persona.metadata.category
});
}
async loadPersonas() {
try {
await fs.access(this.personasDir);
}
catch {
// Create personas directory if it doesn't exist
await fs.mkdir(this.personasDir, { recursive: true });
console.error(`Created personas directory at: ${this.personasDir}`);
return;
}
try {
const files = await fs.readdir(this.personasDir);
const markdownFiles = files.filter(file => file.endsWith('.md'));
this.personas.clear();
for (const file of markdownFiles) {
try {
const filePath = path.join(this.personasDir, file);
const fileContent = await fs.readFile(filePath, 'utf-8');
// Use secure YAML parser
let parsed;
try {
parsed = SecureYamlParser.safeMatter(fileContent);
}
catch (error) {
if (error instanceof SecurityError) {
console.error(`Security threat detected in persona ${file}: ${error.message}`);
continue;
}
throw error;
}
const metadata = parsed.data;
const content = parsed.content;
if (!metadata.name) {
metadata.name = path.basename(file, '.md');
}
// Generate unique ID if not present
let uniqueId = metadata.unique_id;
if (!uniqueId) {
const authorForId = metadata.author || this.getCurrentUserForAttribution();
uniqueId = generateUniqueId(metadata.name, authorForId);
console.error(`Generated unique ID for ${metadata.name}: ${uniqueId}`);
}
// Set default values for new metadata fields
if (!metadata.category)
metadata.category = 'general';
if (!metadata.age_rating)
metadata.age_rating = 'all';
if (!metadata.content_flags)
metadata.content_flags = [];
if (metadata.ai_generated === undefined)
metadata.ai_generated = false;
if (!metadata.generation_method)
metadata.generation_method = 'human';
if (!metadata.price)
metadata.price = 'free';
if (!metadata.license)
metadata.license = 'CC-BY-SA-4.0';
const persona = {
metadata,
content,
filename: file,
unique_id: uniqueId,
};
this.personas.set(file, persona);
console.error(`Loaded persona: ${metadata.name} (${uniqueId})`);
}
catch (error) {
console.error(`Error loading persona ${file}: ${error}`);
}
}
}
catch (error) {
console.error(`Error reading personas directory: ${error}`);
}
}
async listPersonas() {
const personaList = Array.from(this.personas.values()).map(persona => ({
filename: persona.filename,
unique_id: persona.unique_id,
name: persona.metadata.name,
description: persona.metadata.description,
triggers: persona.metadata.triggers || [],
version: persona.metadata.version || "1.0",
author: persona.metadata.author || "Unknown",
category: persona.metadata.category || 'general',
age_rating: persona.metadata.age_rating || 'all',
price: persona.metadata.price || 'free',
ai_generated: persona.metadata.ai_generated || false,
active: this.activePersona === persona.filename,
}));
return {
content: [
{
type: "text",
text: `${this.getPersonaIndicator()}Available Personas (${personaList.length}):\n\n` +
personaList.map(p => `${p.active ? 'š¹ ' : 'ā«ļø '}**${p.name}** (${p.unique_id})\n` +
` ${p.description}\n` +
` š ${p.category} | š ${p.author} | š ${p.price} | ${p.ai_generated ? 'š¤ AI' : 'š¤ Human'}\n` +
` Age: ${p.age_rating} | Version: ${p.version}\n` +
` Triggers: ${p.triggers.join(', ') || 'None'}\n`).join('\n'),
},
],
};
}
async activatePersona(personaIdentifier) {
// Try to find persona by filename first, then by name
let persona = this.personas.get(personaIdentifier);
if (!persona) {
// Search by name
persona = Array.from(this.personas.values()).find(p => p.metadata.name.toLowerCase() === personaIdentifier.toLowerCase());
}
if (!persona) {
throw new McpError(ErrorCode.InvalidParams, `Persona not found: ${personaIdentifier}`);
}
this.activePersona = persona.filename;
return {
content: [
{
type: "text",
text: `${this.getPersonaIndicator()}Persona Activated: **${persona.metadata.name}**\n\n` +
`${persona.metadata.description}\n\n` +
`**Instructions:**\n${persona.content}`,
},
],
};
}
async getActivePersona() {
if (!this.activePersona) {
return {
content: [
{
type: "text",
text: `${this.getPersonaIndicator()}No persona is currently active.`,
},
],
};
}
const persona = this.personas.get(this.activePersona);
if (!persona) {
this.activePersona = null;
return {
content: [
{
type: "text",
text: `${this.getPersonaIndicator()}Active persona not found. Deactivated.`,
},
],
};
}
return {
content: [
{
type: "text",
text: `${this.getPersonaIndicator()}Active Persona: **${persona.metadata.name}**\n\n` +
`${persona.metadata.description}\n\n` +
`File: ${persona.filename}\n` +
`Version: ${persona.metadata.version || '1.0'}\n` +
`Author: ${persona.metadata.author || 'Unknown'}`,
},
],
};
}
async deactivatePersona() {
const wasActive = this.activePersona !== null;
const indicator = this.getPersonaIndicator();
this.activePersona = null;
return {
content: [
{
type: "text",
text: wasActive
? `${indicator}ā
Persona deactivated. Back to default mode.`
: "No persona was active.",
},
],
};
}
async getPersonaDetails(personaIdentifier) {
// Try to find persona by filename first, then by name
let persona = this.personas.get(personaIdentifier);
if (!persona) {
// Search by name
persona = Array.from(this.personas.values()).find(p => p.metadata.name.toLowerCase() === personaIdentifier.toLowerCase());
}
if (!persona) {
throw new McpError(ErrorCode.InvalidParams, `Persona not found: ${personaIdentifier}`);
}
return {
content: [
{
type: "text",
text: `${this.getPersonaIndicator()}š **${persona.metadata.name}** Details\n\n` +
`**Description:** ${persona.metadata.description}\n` +
`**File:** ${persona.filename}\n` +
`**Version:** ${persona.metadata.version || '1.0'}\n` +
`**Author:** ${persona.metadata.author || 'Unknown'}\n` +
`**Triggers:** ${persona.metadata.triggers?.join(', ') || 'None'}\n\n` +
`**Full Content:**\n\`\`\`\n${persona.content}\n\`\`\``,
},
],
};
}
async reloadPersonas() {
await this.loadPersonas();
return {
content: [
{
type: "text",
text: `${this.getPersonaIndicator()}š Reloaded ${this.personas.size} personas from ${this.personasDir}`,
},
],
};
}
// checkRateLimit and fetchFromGitHub are now handled by GitHubClient
async browseMarketplace(category) {
try {
const { items, categories } = await this.marketplaceBrowser.browseMarketplace(category);
const text = this.marketplaceBrowser.formatBrowseResults(items, categories, category, this.getPersonaIndicator());
return {
content: [
{
type: "text",
text: text,
},
],
};
}
catch (error) {
return {
content: [
{
type: "text",
text: `${this.getPersonaIndicator()}ā Error browsing marketplace: ${error}`,
},
],
};
}
}
async searchMarketplace(query) {
try {
const items = await this.marketplaceSearch.searchMarketplace(query);
const text = this.marketplaceSearch.formatSearchResults(items, query, this.getPersonaIndicator());
return {
content: [
{
type: "text",
text: text,
},
],
};
}
catch (error) {
return {
content: [
{
type: "text",
text: `${this.getPersonaIndicator()}ā Error searching marketplace: ${error}`,
},
],
};
}
}
async getMarketplacePersona(path) {
try {
const { metadata, content } = await this.personaDetails.getMarketplacePersona(path);
const text = this.personaDetails.formatPersonaDetails(metadata, content, path, this.getPersonaIndicator());
return {
content: [
{
type: "text",
text: text,
},
],
};
}
catch (error) {
return {
content: [
{
type: "text",
text: `${this.getPersonaIndicator()}ā Error fetching persona: ${error}`,
},
],
};
}
}
async installPersona(inputPath) {
try {
const result = await this.personaInstaller.installPersona(inputPath);
if (!result.success) {
return {
content: [
{
type: "text",
text: `${this.getPersonaIndicator()}ā ļø ${result.message}`,
},
],
};
}
// Reload personas to include the new one
await this.loadPersonas();
const text = this.personaInstaller.formatInstallSuccess(result.metadata, result.filename, this.personas.size, this.getPersonaIndicator());
return {
content: [
{
type: "text",
text: text,
},
],
};
}
catch (error) {
return {
content: [
{
type: "text",
text: `${this.getPersonaIndicator()}ā Error installing persona: ${error}`,
},
],
};
}
}
async submitPersona(personaIdentifier) {
// Find the persona in local collection
let persona = this.personas.get(personaIdentifier);
if (!persona) {
// Search by name
persona = Array.from(this.personas.values()).find(p => p.metadata.name.toLowerCase() === personaIdentifier.toLowerCase());
}
if (!persona) {
return {
content: [
{
type: "text",
text: `${this.getPersonaIndicator()}ā Persona not found: ${personaIdentifier}`,
},
],
};
}
// Validate persona content before submission
try {
// Read the full persona file content
const fullPath = path.join(this.personasDir, persona.filename);
const fileContent = await fs.readFile(fullPath, 'utf-8');
// Validate content for security threats
const contentValidation = ContentValidator.validateAndSanitize(fileContent);
if (!contentValidation.isValid && contentValidation.severity === 'critical') {
return {
content: [
{
type: "text",
text: `${this.getPersonaIndicator()}ā **Security Validation Failed**\n\n` +
`This persona contains content that could be used for prompt injection attacks:\n` +
`⢠${contentValidation.detectedPatterns?.join('\n⢠')}\n\n` +
`Please remove these patterns before submitting to the marketplace.`,
},
],
};
}
// Validate metadata
const metadataValidation = ContentValidator.validateMetadata(persona.metadata);
if (!metadataValidation.isValid) {
return {
content: [
{
type: "text",
text: `${this.getPersonaIndicator()}ā ļø **Metadata Security Warning**\n\n` +
`The persona metadata contains potentially problematic content:\n` +
`⢠${metadataValidation.detectedPatterns?.join('\n⢠')}\n\n` +
`Please fix these issues before submitting.`,
},
],
};
}
}
catch (error) {
return {
content: [
{
type: "text",
text: `${this.getPersonaIndicator()}ā Error validating persona: ${error}`,
},
],
};
}
const { githubIssueUrl } = this.personaSubmitter.generateSubmissionIssue(persona);
const text = this.personaSubmitter.formatSubmissionResponse(persona, githubIssueUrl, this.getPersonaIndicator());
return {
content: [
{
type: "text",
text: text,
},
],
};
}
// User identity management
async setUserIdentity(username, email) {
try {
if (!username || username.trim().length === 0) {
return {
content: [
{
type: "text",
text: `${this.getPersonaIndicator()}ā Username cannot be empty`,
},
],
};
}
// Validate and sanitize username
const validatedUsername = validateUsername(username);
// Validate email if provided
let validatedEmail;
if (email) {
const sanitizedEmail = sanitizeInput(email, 100);
if (!VALIDATION_PATTERNS.SAFE_EMAIL.test(sanitizedEmail)) {
throw new Error('Invalid email format');
}
validatedEmail = sanitizedEmail;
}
// Set the validated user identity
this.currentUser = validatedUsername;
if (validatedEmail) {
process.env.DOLLHOUSE_EMAIL = validatedEmail;
}
return {
content: [
{
type: "text",
text: `${this.getPersonaIndicator()}ā
**User Identity Set**\n\n` +
`š¤ **Username:** ${validatedUsername}\n` +
`${validatedEmail ? `š§ **Email:** ${validatedEmail}\n` : ''}` +
`\nšÆ **Next Steps:**\n` +
`⢠New personas you create will be attributed to "${validatedUsername}"\n` +
`⢠Set environment variable \`DOLLHOUSE_USER=${validatedUsername}\` to persist this setting\n` +
`${validatedEmail ? `⢠Set environment variable \`DOLLHOUSE_EMAIL=${validatedEmail}\` for contact info\n` : ''}` +
`⢠Use \`clear_user_identity\` to return to anonymous mode`,
},
],
};
}
catch (error) {
return {
content: [
{
type: "text",
text: `${this.getPersonaIndicator()}ā **Validation Error**\n\n` +
`${error}\n\n` +
`Please provide a valid username (alphanumeric characters, hyphens, underscores, dots only).`,
},
],
};
}
}
async getUserIdentity() {
const email = process.env.DOLLHOUSE_EMAIL;
if (!this.currentUser) {
return {
content: [
{
type: "text",
text: `${this.getPersonaIndicator()}š¤ **User Identity: Anonymous**\n\n` +
`š **Status:** Anonymous mode\n` +
`š **Attribution:** Personas will use anonymous IDs\n\n` +
`**To set your identity:**\n` +
`⢠Use: \`set_user_identity "your-username"\`\n` +
`⢠Or set environment variable: \`DOLLHOUSE_USER=your-username\``,
},
],
};
}
return {
content: [
{
type: "text",
text: `${this.getPersonaIndicator()}š¤ **User Identity: ${this.currentUser}**\n\n` +
`ā
**Status:** Authenticated\n` +
`š¤ **Username:** ${this.currentUser}\n` +
`${email ? `š§ **Email:** ${email}\n` : ''}` +
`š **Attribution:** New personas will be credited to "${this.currentUser}"\n\n` +
`**Environment Variables:**\n` +
`⢠\`DOLLHOUSE_USER=${this.currentUser}\`\n` +
`${email ? `⢠\`DOLLHOUSE_EMAIL=${email}\`\n` : ''}` +
`\n**Management:**\n` +
`⢠Use \`clear_user_identity\` to return to anonymous mode\n` +
`⢠Use \`set_user_identity "new-username"\` to change username`,
},
],
};
}
async clearUserIdentity() {
const wasSet = this.currentUser !== null;
const previousUser = this.currentUser;
this.currentUser = null;
return {
content: [
{
type: "text",
text: wasSet
? `${this.getPersonaIndicator()}ā
**User Identity Cleared**\n\n` +
`š¤ **Previous:** ${previousUser}\n` +
`š **Current:** Anonymous mode\n\n` +
`š **Effect:** New personas will use anonymous IDs\n\n` +
`ā ļø **Note:** This only affects the current session.\n` +
`To persist this change, unset the \`DOLLHOUSE_USER\` environment variable.`
: `${this.getPersonaIndicator()}ā¹ļø **Already in Anonymous Mode**\n\n` +
`š¤ No user identity was set.\n\n` +
`Use \`set_user_identity "username"\` to set your identity.`,
},
],
};
}
getCurrentUserForAttribution() {
return this.currentUser || generateAnonymousId();
}
// Chat-based persona management tools
async createPersona(name, description, category, instructions, triggers) {
try {
// Validate required fields
if (!name || !description || !category || !instructions) {
return {
content: [
{
type: "text",
text: `${this.getPersonaIndicator()}ā **Missing Required Fields**\n\n` +
`Please provide all required fields:\n` +
`⢠**name**: Display name for the persona\n` +
`⢠**description**: Brief description of what it does\n` +
`⢠**category**: creative, professional, educational, gaming, or personal\n` +
`⢠**instructions**: The persona's behavioral guidelines\n\n` +
`**Optional:**\n` +
`⢠**triggers**: Comma-separated keywords for activation`,
},
],
};
}
// Sanitize and validate inputs
const sanitizedName = sanitizeInput(name, 100);
const sanitizedDescription = sanitizeInput(description, 500);
const sanitizedInstructions = sanitizeInput(instructions);
const sanitizedTriggers = triggers ? sanitizeInput(triggers, 200) : '';
// Validate name length and format
if (sanitizedName.length < 2) {
throw new Error('Persona name must be at least 2 characters long');
}
// Validate category
const validatedCategory = validateCategory(category);
// Validate content sizes
validateContentSize(sanitizedInstructions, SECURITY_LIMITS.MAX_CONTENT_LENGTH);
validateContentSize(sanitizedDescription, 2000); // 2KB max for description
// Validate content for security threats
const nameValidation = ContentValidator.validateAndSanitize(sanitizedName);
if (!nameValidation.isValid) {
throw new Error(`Name contains prohibited content: ${nameValidation.detectedPatterns?.join(', ')}`);
}
const descValidation = ContentValidator.validateAndSanitize(sanitizedDescription);
if (!descValidation.isValid) {
throw new Error(`Description contains prohibited content: ${descValidation.detectedPatterns?.join(', ')}`);
}
const instructionsValidation = ContentValidator.validateAndSanitize(sanitizedInstructions);
if (!instructionsValidation.isValid && instructionsValidation.severity === 'critical') {
throw new Error(`Instructions contain security threats: ${instructionsValidation.detectedPatterns?.join(', ')}`);
}
// Generate metadata
const author = this.getCurrentUserForAttribution();
const uniqueId = generateUniqueId(sanitizedName, this.currentUser || undefined);
const filename = validateFilename(`${slugify(sanitizedName)}.md`);
const filePath = path.join(this.personasDir, filename);
// Check if file already exists
try {
await fs.access(filePath);
return {
content: [
{
type: "text",
text: `${this.getPersonaIndicator()}ā ļø **Persona Already Exists**\n\n` +
`A persona file named "${filename}" already exists.\n` +
`Use \`edit_persona\` to modify it, or choose a different name.`,
},
],
};
}
catch {
// File doesn't exist, proceed with creation
}
// Parse and sanitize triggers
const triggerList = sanitizedTriggers ?
sanitizedTriggers.split(',').map(t => sanitizeInput(t.trim(), 50)).filter(t => t.length > 0) :
[];
// Create persona metadata with sanitized values
const metadata = {
name: sanitizedName,
description: sanitizedDescription,
unique_id: uniqueId,
author,
triggers: triggerList,
version: "1.0",
category: validatedCategory,
age_rating: "all",
content_flags: ["user-created"],
ai_generated: true,
generation_method: "Claude",
price: "free",
revenue_split: "80/20",
license: "CC-BY-SA-4.0",
created_date: new Date().toISOString().slice(0, 10)
};
// Create full persona content with sanitized values
const frontmatter = Object.entries(metadata)
.map(([key, value]) => `${key}: ${JSON.stringify(value)}`)
.join('\n');
const personaContent = `---
${frontmatter}
---
# ${sanitizedName}
${sanitizedInstructions}
## Response Style
- Follow the behavioral guidelines above
- Maintain consistency with the persona's character
- Adapt responses to match the intended purpose
## Usage Notes
- Created via DollhouseMCP chat interface
- Author: ${author}
- Version: 1.0`;
// Validate final content size
validateContentSize(personaContent, SECURITY_LIMITS.MAX_PERSONA_SIZE_BYTES);
try {
// Write the file
await fs.writeFile(filePath, personaContent, 'utf-8');
// Reload personas to include the new one
await this.loadPersonas();
return {
content: [
{
type: "text",
text: `${this.getPersonaIndicator()}ā
**Persona Created Successfully!**\n\n` +
`š **${name}** by ${author}\n` +
`š Category: ${category}\n` +
`š Unique ID: ${uniqueId}\n` +
`š Saved as: ${filename}\n` +
`š Total personas: ${this.personas.size}\n\n` +
`šÆ **Ready to use:** \`activate_persona "${name}"\`\n` +
`š¤ **Share it:** \`submit_persona "${name}"\`\n` +
`āļø **Edit it:** \`edit_persona "${name}" "field" "new value"\``,
},
],
};
}
catch (error) {
return {
content: [
{
type: "text",
text: `${this.getPersonaIndicator()}ā **Error Creating Persona**\n\n` +
`Failed to write persona file: ${error}\n\n` +
`Please check permissions and try again.`,
},
],
};
}
}
catch (error) {
return {
content: [
{
type: "text",
text: `${this.getPersonaIndicator()}ā **Validation Error**\n\n` +
`${error}\n\n` +
`Please fix the issue and try again.`,
},
],
};
}
}
async editPersona(personaIdentifier, field, value) {
if (!personaIdentifier || !field || !value) {
return {
content: [
{
type: "text",
text: `${this.getPersonaIndicator()}ā **Missing Parameters**\n\n` +
`Usage: \`edit_persona "persona_name" "field" "new_value"\`\n\n` +
`**Editable fields:**\n` +
`⢠**name** - Display name\n` +
`⢠**description** - Brief description\n` +
`⢠**category** - creative, professional, educational, gaming, personal\n` +
`⢠**instructions** - Main persona content\n` +
`⢠**triggers** - Comma-separated keywords\n` +
`⢠**version** - Version number`,
},
],
};
}
// Find the persona
let persona = this.personas.get(personaIdentifier);
if (!persona) {
// Search by name
persona = Array.from(this.personas.values()).find(p => p.metadata.name.toLowerCase() === personaIdentifier.toLowerCase());
}
if (!persona) {
return {
content: [
{
type: "text",
text: `${this.getPersonaIndicator()}ā **Persona Not Found**\n\n` +
`Could not find persona: "${personaIdentifier}"\n\n` +
`Use \`list_personas\` to see available personas.`,
},
],
};
}
const validFields = ['name', 'description', 'category', 'instructions', 'triggers', 'version'];
if (!validFields.includes(field.toLowerCase())) {
return {
content: [
{
type: "text",
text: `${this.getPersonaIndicator()}ā **Invalid Field**\n\n` +
`Field "${field}" is not editable.\n\n` +
`**Valid fields:** ${validFields.join(', ')}`,
},
],
};
}
let filePath = path.join(this.personasDir, persona.filename);
let isDefaultPersona = DEFAULT_PERSONAS.includes(persona.filename);
try {
// Read current file
const fileContent = await fs.readFile(filePath, 'utf-8');
// Use secure YAML parser
let parsed;
try {
parsed = SecureYamlParser.safeMatter(fileContent);
}
catch (error) {
if (error instanceof SecurityError) {
return {
content: [
{
type: "text",
text: `${this.getPersonaIndicator()}ā **Security Error**\n\n` +
`Cannot edit persona due to security threat: ${error.message}`,
},
],
};
}
throw error;
}
// If editing a default persona, create a copy instead
if (isDefaultPersona) {
// Generate unique ID for the copy
const author = this.currentUser || generateAnonymousId();
const uniqueId = generateUniqueId(persona.metadata.name, author);
const newFilename = `${uniqueId}.md`;
const newFilePath = path.join(this.personasDir, newFilename);
// Create copy of the default persona
await fs.copyFile(filePath, newFilePath);
// Update file path to point to the copy
filePath = newFilePath;
// Update the unique_id in the metadata
parsed.data.unique_id = uniqueId;
parsed.data.author = author;
}
// Update the appropriate field
const normalizedField = field.toLowerCase();
// Validate the new value for security threats
const valueValidation = ContentValidator.validateAndSanitize(value);
if (!valueValidation.isValid && valueValidation.severity === 'critical') {
return {
content: [
{
type: "text",
text: `${this.getPersonaIndicator()}ā **Security Validation Failed**\n\n` +
`The new value contains prohibited content:\n` +
`⢠${valueValidation.detectedPatterns?.join('\n⢠')}\n\n` +
`Please remove these patterns and try again.`,
},
],
};
}
// Use sanitized value if needed
const sanitizedValue = valueValidation.sanitizedContent || value;
if (normalizedField === 'instructions') {
// Update the main content
parsed.content = sanitizedValue;
}
else if (normalizedField === 'triggers') {
// Parse triggers as comma-separated list
parsed.data[normalizedField] = sanitizedValue.split(',').map(t => t.trim()).filter(t => t.length > 0);
}
else if (normalizedField === 'category') {
// Validate category
const validCategories = ['creative', 'professional', 'educational', 'gaming', 'personal'];
if (!validCategories.includes(sanitizedValue.toLowerCase())) {
return {
content: [
{
type: "text",
text: `${this.getPersonaIndicator()}ā **Invalid Category**\n\n` +
`Category must be one of: ${validCategories.join(', ')}\n` +
`You provided: "${sanitizedValue}"`,
},
],
};
}
parsed.data[normalizedField] = sanitizedValue.toLowerCase();
}
else {
// Update metadata field
parsed.data[normalizedField] = sanitizedValue;
}
// Update version and modification info
if (normalizedField !== 'version') {
const currentVersion = parsed.data.version || '1.0';
const versionParts = currentVersion.split('.').map(Number);
versionParts[1] = (versionParts[1] || 0) + 1;
parsed.data.version = versionParts.join('.');
}
// Regenerate file content
// Use secure YAML stringification
const secureParser = SecureYamlParser.createSecureMatterParser();
const updatedContent = secureParser.stringify(parsed.content, parsed.data);
// Write updated file
await fs.writeFile(filePath, updatedContent, 'utf-8');
// Reload personas
await this.loadPersonas();
return {
content: [
{
type: "text",
text: `${this.getPersonaIndicator()}ā
**Persona Updated Successfully!**\n\n` +
(isDefaultPersona ? `š **Note:** Created a copy of the default persona to preserve the original.\n\n` : '') +
`š **${parsed.data.name || persona.metadata.name}**\n` +
`š **Field Updated:** ${field}\n` +
`š **New Value:** ${normalizedField === 'instructions' ? 'Content updated' : value}\n` +
`š **Version:** ${parsed.data.version}\n` +
(isDefaultPersona ? `š **New ID:** ${parsed.data.unique_id}\n` : '') +
`\n` +
`Use \`get_persona_details "${parsed.data.name || persona.metadata.name}"\` to see all changes.`,
},
],
};
}
catch (error) {
return {
content: [
{
type: "text",
text: `${this.getPersonaIndicator()}ā **Error Updating Persona**\n\n` +
`Failed to update persona: ${error}\n\n` +
`Please check file permissions and try again.`,
},
],
};
}
}
async validatePersona(personaIdentifier) {
if (!personaIdentifier) {
return {
content: [
{
type: "text",
text: `${this.getPersonaIndicator()}ā **Missing Persona Identifier**\n\n` +
`Usage: \`validate_persona "persona_name"\`\n\n` +
`Use \`list_personas\` to see available personas.`,
},
],
};
}
// Find the persona
let persona = this.personas.get(personaIdentifier);
if (!persona) {
// Search by name
persona = Array.from(this.personas.values()).find(p => p.metadata.name.toLowerCase() === personaIdentifier.toLowerCase());
}
if (!persona) {
return {
content: [
{
type: "text",
text: `${this.getPersonaIndicator()}ā **Persona Not Found**\n\n` +
`Could not find persona: "${personaIdentifier}"\n\n` +
`Use \`list_personas\` to see available personas.`,
},
],
};
}
// Validation checks
const issues = [];
const warnings = [];
const metadata = persona.metadata;
// Required field checks
if (!metadata.name || metadata.name.trim().length === 0) {
issues.push("Missing or empty 'name' field");
}
if (!metadata.description || metadata.description.trim().length === 0) {
issues.push("Missing or empty 'description' field");
}
if (!persona.content || persona.content.trim().length < 50) {
issues.push("Persona content is too short (minimum 50 characters)");
}
// Category validation
const validCategories = ['creative', 'professional', 'educational', 'gaming', 'personal', 'general'];
if (metadata.category && !validCategories.includes(metadata.category)) {
issues.push(`Invalid category '${metadata.category}'. Must be one of: ${validCategories.join(', ')}`);
}
// Age rating validation
const validAgeRatings = ['all', '13+', '18+'];
if (metadata.age_rating && !validAgeRatings.includes(metadata.age_rating)) {
warnings.push(`Invalid age_rating '${metadata.age_rating}'. Should be one of: ${validAgeRatings.join(', ')}`);
}
// Optional field warnings
if (!metadata.triggers || metadata.triggers.length === 0) {
warnings.push("No trigger keywords defined - users may have difficulty finding this persona");
}
if (!metadata.version) {
warnings.push("No version specified - defaulting to '1.0'");
}
if (!metadata.unique_id) {
warnings.push("No unique_id - one will be generated automatically");
}
// Content quality checks
if (persona.content.length > 5000) {
warnings.push("Persona content is very long - consider breaking it into sections");
}
if (metadata.name && metadata.name.length > 50) {
warnings.push("Persona name is very long - consider shortening for better display");
}
if (metadata.description && metadata.description.length > 200) {
warnings.push("Description is very long - consider keeping it under 200 characters");
}
// Generate validation report
let report = `${this.getPersonaIndicator()}š **Validation Report: ${persona.metadata.name}**\n\n`;
if (issues.length === 0 && warnings.length === 0) {
report += `ā
**All Checks Passed!**\n\n` +
`š **Persona:** ${metadata.name}\n` +
`š **Category:** ${metadata.category || 'general'}\n` +
`š **Version:** ${metadata.version || '1.0'}\n` +
`š **Content Length:** ${persona.content.length} characters\n` +
`š **Triggers:** ${metadata.triggers?.length || 0} keywords\n\n` +
`This persona meets all validation requirements and is ready for use!`;
}
else {
if (issues.length > 0) {
report += `ā **Issues Found (${issues.length}):**\n`;
issues.forEach((issue, i) => {
report += ` ${i + 1}. ${issue}\n`;
});
report += '\n';
}
if (warnings.length > 0) {
report += `ā ļø **Warnings (${warnings.length}):**\n`;
warnings.forEach((warning, i) => {
report += ` ${i + 1}. ${warning}\n`;
});
report += '\n';
}
if (issues.length > 0) {
report += `**Recommendation:** Fix the issues above before using this persona.\n`;
report += `Use \`edit_persona "${persona.metadata.name}" "field" "value"\` to make corrections.`;
}
else {
report += `**Status:** Persona is functional but could be improved.\n`;
report += `Address warnings above for optimal performance.`;
}
}
return {
content: [
{
type: "text",
text: report,
},
],
};
}
// retryNetworkOperation is now handled by UpdateChecker
// Auto-update management tools
async checkForUpdates() {
const { text } = await this.updateManager.checkForUpdates();
return {
content: [{ type: "text", text: this.getPersonaIndicator() + text }]
};
}
// Update helper methods are now handled by UpdateManager
async updateServer(confirm) {
if (!confirm) {
return {
content: [{
type: "text",
text: this.getPersonaIndicator() +
'ā ļø **Update Confirmation Required**\n\n' +
'To proceed with the update, you must confirm:\n' +
'`update_server true`\n\n' +
'**What will happen:**\n' +
'⢠Backup current version\n' +
'⢠Pull latest changes from GitHub\n' +
'⢠Update dependencies\n' +
'⢠Rebuild TypeScript\n' +
'⢠Restart server (will disconnect temporarily)\n\n' +
'**Prerequisites:**\n' +
'⢠Git repository must be clean (no uncommitted changes)\n' +
'⢠Network connection required\n' +
'⢠Sufficient disk space for backup'
}]
};
}
const { text } = await this.updateManager.updateServer(confirm, this.getPersonaIndicator());
return {
content: [{ type: "text", text }]
};
}
// Rollback helper methods are now handled by UpdateManager
async rollbackUpdate(confirm) {
const { text } = await this.updateManager.rollbackUpdate(confirm, this.getPersonaIndicator());
return {
content: [{ type: "text", text }]
};
}
// Version and git info methods are now handled by UpdateManager
// Status helper methods are now handled by UpdateManager
async getServerStatus() {
// Add persona information to the status
const personaInfo = `
**š Persona Information:**
⢠**Total Personas:** ${this.personas.size}
⢠**Active Persona:** ${this.activePersona || 'None'}
⢠**User Identity:** ${this.currentUser || 'Anonymous'}
⢠**Personas Directory:** ${this.personasDir}`;
const { text } = await this.updateManager.getServerStatus(this.getPersonaIndicator());
// Insert persona info into the status text
const updat