Skip to main content
Glama

submit_content

Submit local content, including AI behavioral personas, to the DollhouseMCP collection for community review. Share personas or other content types by specifying the name or filename for submission.

Instructions

Submit local content to the collection for community review. Use this when users want to 'share their persona' or 'submit a persona to the collection'. This handles all content types including personas (AI behavioral profiles).

Input Schema

TableJSON Schema
NameRequiredDescriptionDefault
contentYesThe content name or filename to submit. For personas, use the persona's name (e.g., 'Creative Writer') or filename. The system will recognize it as a persona based on its metadata.

Implementation Reference

  • Registration of the submit_collection_content tool (matches submit_content calls in tests, likely the target tool). Calls server.submitContent(content) as handler.
    { tool: { name: "submit_collection_content", description: "Submit a single element TO the DollhouseMCP community collection (via your GitHub portfolio). This first uploads the element to your personal GitHub portfolio, then creates a submission issue for the community collection. Use this when users want to share their custom elements with the community. This handles all content types including personas (AI behavioral profiles).", inputSchema: { type: "object", properties: { content: { type: "string", description: "The content name or filename to submit. For personas, use the persona's name (e.g., 'Creative Writer') or filename. The system will recognize it as a persona based on its metadata.", }, }, required: ["content"], }, }, handler: (args: any) => server.submitContent(args.content) },
  • Input schema for the tool: object with 'content' string parameter.
    type: "object", properties: { content: { type: "string", description: "The content name or filename to submit. For personas, use the persona's name (e.g., 'Creative Writer') or filename. The system will recognize it as a persona based on its metadata.", }, }, required: ["content"], },
  • Core handler logic in SubmitToPortfolioTool.execute(). Takes {name, type?}, performs 8-step workflow: param validation, auth, smart content discovery across types, security checks, metadata extraction, GitHub repo setup, upload with duplicate detection/retry, optional collection issue creation. Returns result with URLs.
    async execute(params: SubmitToPortfolioParams): Promise<SubmitToPortfolioResult> { const startTime = Date.now(); const timings: Record<string, number> = {}; logger.info('šŸš€ SUBMISSION WORKFLOW STARTING', { params: JSON.stringify(params), timestamp: new Date().toISOString() }); try { // Step 1: Validate and normalize input parameters logger.info('šŸ“‹ Step 1/8: Validating parameters...'); const step1Start = Date.now(); const validationResult = await this.validateAndNormalizeParams(params); timings['validation'] = Date.now() - step1Start; logger.info(`āœ… Step 1 complete (${timings['validation']}ms)`); if (!validationResult.success) { return validationResult.error!; } const safeName = validationResult.safeName!; // Step 2: Check authentication status logger.info('šŸ” Step 2/8: Checking authentication...'); const step2Start = Date.now(); const authResult = await this.checkAuthentication(); timings['authentication'] = Date.now() - step2Start; logger.info(`āœ… Step 2 complete (${timings['authentication']}ms)`, { username: authResult.authStatus?.username, hasToken: !!authResult.authStatus?.hasToken }); if (!authResult.success) { return authResult.error!; } const authStatus = authResult.authStatus!; // Step 3: Find content locally with smart type detection logger.info('šŸ” Step 3/8: Finding content locally...'); const step3Start = Date.now(); const contentResult = await this.discoverContentWithTypeDetection(safeName!, params.type, params.name); timings['contentDiscovery'] = Date.now() - step3Start; logger.info(`āœ… Step 3 complete (${timings['contentDiscovery']}ms)`, { found: contentResult.success, elementType: contentResult.elementType, path: contentResult.localPath }); if (!contentResult.success) { return contentResult.error!; } const elementType = contentResult.elementType!; const localPath = contentResult.localPath!; // Step 4: Validate file and content security logger.info('šŸ”’ Step 4/8: Validating security...'); const step4Start = Date.now(); const securityResult = await this.validateFileAndContent(localPath); timings['security'] = Date.now() - step4Start; logger.info(`āœ… Step 4 complete (${timings['security']}ms)`); if (!securityResult.success) { return securityResult.error!; } const content = securityResult.content!; // Step 5: Prepare metadata for element logger.info('šŸ“ Step 5/8: Preparing metadata...'); const step5Start = Date.now(); const metadata = await this.prepareElementMetadata(safeName!, elementType, authStatus, localPath); timings['metadata'] = Date.now() - step5Start; logger.info(`āœ… Step 5 complete (${timings['metadata']}ms)`, { author: metadata.author, elementName: safeName }); // Step 6: Set up GitHub repository access logger.info('šŸ”§ Step 6/8: Setting up GitHub repository...'); const step6Start = Date.now(); const repoResult = await this.setupGitHubRepository(authStatus); timings['repoSetup'] = Date.now() - step6Start; logger.info(`āœ… Step 6 complete (${timings['repoSetup']}ms)`, { success: repoResult.success }); if (!repoResult.success) { return repoResult.error!; } // Step 7: Submit element to portfolio and handle collection submission logger.info('šŸ“¤ Step 7/8: Submitting to portfolio...'); const step7Start = Date.now(); const result = await this.submitElementAndHandleResponse( safeName!, elementType, metadata, content, authStatus, localPath // Pass file path for collection submission ); timings['submission'] = Date.now() - step7Start; // Step 8: Final reporting timings['total'] = Date.now() - startTime; logger.info('✨ SUBMISSION WORKFLOW COMPLETE', { success: result.success, timings, totalTime: `${timings['total']}ms` }); return result; } catch (error) { // SECURITY ENHANCEMENT (Task #14): Enhanced error handling with token refresh guidance ErrorHandler.logError('submitToPortfolio', error, { elementName: params.name, elementType: params.type }); // Check if error is token-related and provide refresh guidance const errorMessage = error instanceof Error ? error.message : String(error); const isTokenError = errorMessage.toLowerCase().includes('token') || errorMessage.toLowerCase().includes('auth') || errorMessage.toLowerCase().includes('401') || errorMessage.toLowerCase().includes('403'); let formattedError = ErrorHandler.formatForResponse(error); if (isTokenError) { try { // Get current token to determine type for guidance const currentToken = await TokenManager.getGitHubTokenAsync(); if (currentToken) { const tokenType = TokenManager.getTokenType(currentToken); const refreshGuidance = this.formatTokenRefreshGuidance('portfolio submission', tokenType); // Append refresh guidance to error message if (formattedError.message) { formattedError.message += refreshGuidance; } } } catch (tokenError) { // If we can't get token info, provide generic guidance formattedError.message += '\n\nšŸ”„ **Authentication Issue**: Try running `setup_github_auth` to refresh your authentication.'; } } return formattedError; } }
  • Helper for content discovery: smart type detection across all ElementType directories, fuzzy matching, suggestions if not found, handles multiple matches.
    private async discoverContentWithTypeDetection( safeName: string, explicitType?: ElementType, originalName?: string ): Promise<{ success: boolean; elementType?: ElementType; localPath?: string; error?: SubmitToPortfolioResult; }> { let elementType = explicitType; let localPath: string | null = null; if (elementType) { // Type explicitly provided - search in that specific directory only localPath = await this.findLocalContent(safeName, elementType); if (!localPath) { // UX IMPROVEMENT: Provide helpful suggestions for finding content const portfolioManager = PortfolioManager.getInstance(); const elementDir = portfolioManager.getElementDir(elementType); return { success: false, error: { success: false, message: `Could not find ${elementType} named "${originalName || safeName}" in local portfolio.\n\n` + `**Searched in**: ${elementDir}\n\n` + `**Troubleshooting Tips**:\n` + `• Check if the file exists using your file explorer\n` + `• Try using the exact filename (without extension)\n` + `• Use \`list_portfolio\` to see all available ${elementType}\n` + `• If unsure of the type, omit --type and let the system detect it\n\n` + `**Common name formats that work**:\n` + `• "my-element" (kebab-case)\n` + `• "My Element" (with spaces)\n` + `• "MyElement" (PascalCase)\n` + `• Partial matches are supported`, error: 'CONTENT_NOT_FOUND' } }; } } else { // CRITICAL FIX: No type provided - implement smart detection across ALL element types // This prevents the previous hardcoded default to PERSONA and enables proper type detection const detectionResult = await this.detectElementType(safeName); if (!detectionResult.found) { // UX IMPROVEMENT: Enhanced guidance with specific suggestions const availableTypes = Object.values(ElementType).join(', '); // Get suggestions for similar names const suggestions = await this.generateNameSuggestions(safeName); let message = `Content "${originalName || safeName}" not found in portfolio.\n\n`; message += `šŸ” **Searched in all element types**: ${availableTypes}\n\n`; if (suggestions.length > 0) { message += `šŸ’” **Did you mean one of these?**\n`; for (const suggestion of suggestions.slice(0, SEARCH_CONFIG.MAX_SUGGESTIONS)) { message += ` • "${suggestion.name}" (${suggestion.type})\n`; } message += `\n`; } message += `šŸ› ļø **Troubleshooting Steps**:\n`; message += `1. šŸ“ Use \`list_portfolio\` to see all available content\n`; message += `2. šŸ” Check exact spelling and try variations:\n`; message += ` • "${(originalName || safeName).toLowerCase()}" (lowercase)\n`; message += ` • "${(originalName || safeName).replaceAll(/[^a-z0-9]/gi, '-').toLowerCase()}" (normalized)\n`; if ((originalName || safeName).includes('.')) { message += ` • "${(originalName || safeName).replaceAll('.', '')}" (no dots)\n`; } message += `3. šŸŽÆ Specify element type: \`submit_collection_content "${originalName || safeName}" --type=personas\`\n`; message += `4. šŸ“ Check if file exists in portfolio directories\n\n`; message += `šŸ“ **Tip**: The system searches filenames AND metadata names with fuzzy matching.`; return { success: false, error: { success: false, message, error: 'CONTENT_NOT_FOUND' } }; } if (detectionResult.matches.length > 1) { // Multiple matches found - ask user to specify type const matchDetails = detectionResult.matches.map(m => `- ${m.type}: ${m.path}`).join('\n'); return { success: false, error: { success: false, message: `Content "${originalName || safeName}" found in multiple element types:\n\n${matchDetails}\n\n` + `Please specify the element type using the --type parameter to avoid ambiguity.`, error: 'MULTIPLE_MATCHES_FOUND' } }; } // Single match found - use it const match = detectionResult.matches[0]; elementType = match.type; localPath = match.path; logger.info(`Smart detection: Found "${safeName}" as ${elementType}`, { name: safeName, detectedType: elementType, path: localPath }); } return { success: true, elementType, localPath }; } /** * Validates file size and content security before processing * @param localPath Path to the local file to validate * @returns Validation result with content or error response */ private async validateFileAndContent(localPath: string): Promise<{ success: boolean; content?: string; error?: SubmitToPortfolioResult; }> { // SECURITY ENHANCEMENT (Task #7): Validate file path before processing const pathValidation = await this.validatePortfolioPath(localPath); if (!pathValidation.isValid) { return { success: false, error: pathValidation.error }; } // Use the validated safe path for all subsequent operations const safePath = pathValidation.safePath!; // Validate file size before reading const stats = await fs.stat(safePath); if (stats.size > FILE_SIZE_LIMITS.MAX_FILE_SIZE) { SecurityMonitor.logSecurityEvent({ type: 'RATE_LIMIT_EXCEEDED', severity: 'MEDIUM', source: 'SubmitToPortfolioTool.execute', details: `File size ${stats.size} exceeds limit of ${FILE_SIZE_LIMITS.MAX_FILE_SIZE}` }); return { success: false, error: { success: false, message: `File size exceeds ${FILE_SIZE_LIMITS.MAX_FILE_SIZE_MB}MB limit`, error: 'FILE_TOO_LARGE' } }; } // Validate content security const content = await fs.readFile(safePath, 'utf-8'); const validationResult = ContentValidator.validateAndSanitize(content); if (!validationResult.isValid && validationResult.severity === 'critical') { SecurityMonitor.logSecurityEvent({ type: 'CONTENT_INJECTION_ATTEMPT', severity: 'HIGH', source: 'SubmitToPortfolioTool.execute', details: `Critical security issues detected: ${validationResult.detectedPatterns?.join(', ')}` }); return { success: false, error: { success: false, message: `Content validation failed: ${validationResult.detectedPatterns?.join(', ')}`, error: 'VALIDATION_FAILED' } }; } return { success: true, content }; } /** * Prepares metadata for the portfolio element * @param safeName The normalized name of the element * @param elementType The type of the element * @param authStatus Authentication status containing username * @returns Metadata object for the element */ private async prepareElementMetadata( safeName: string, elementType: ElementType, authStatus: any, filePath?: string ): Promise<PortfolioElementMetadata> { // Try to extract metadata from the file if path is provided let fileMetadata: Record<string, any> | null = null; if (filePath) { fileMetadata = await this.extractElementMetadata(filePath); } // TYPE SAFETY: Define extended metadata interface for better type safety interface ExtendedMetadata extends PortfolioElementMetadata { triggers?: string[]; category?: string; age_rating?: string; ai_generated?: boolean; generation_method?: string; license?: string; tags?: string[]; } // Build metadata with real values from file, falling back to defaults const metadata: ExtendedMetadata = { name: safeName, description: fileMetadata?.description || fileMetadata?.summary || `${elementType} submitted from local portfolio`, author: authStatus.username || fileMetadata?.author || 'unknown', created: fileMetadata?.created || fileMetadata?.created_date || new Date().toISOString(), updated: fileMetadata?.updated || fileMetadata?.modified || new Date().toISOString(), version: fileMetadata?.version || '1.0.0' }; // Add additional metadata fields if present (with type safety) if (fileMetadata) { // Preserve other metadata fields that might be useful if (fileMetadata.triggers && Array.isArray(fileMetadata.triggers)) { metadata.triggers = fileMetadata.triggers; } if (fileMetadata.category && typeof fileMetadata.category === 'string') { metadata.category = fileMetadata.category; } if (fileMetadata.age_rating && typeof fileMetadata.age_rating === 'string') { metadata.age_rating = fileMetadata.age_rating; } if (fileMetadata.ai_generated !== undefined) { metadata.ai_generated = Boolean(fileMetadata.ai_generated); } if (fileMetadata.generation_method && typeof fileMetadata.generation_method === 'string') { metadata.generation_method = fileMetadata.generation_method; } if (fileMetadata.license && typeof fileMetadata.license === 'string') { metadata.license = fileMetadata.license; } if (fileMetadata.tags && Array.isArray(fileMetadata.tags)) { metadata.tags = fileMetadata.tags; } } logger.info('Prepared element metadata', { elementName: safeName, hasFileMetadata: !!fileMetadata, description: metadata.description.substring(0, 100) // Log first 100 chars }); return metadata; } /** * Formats metadata as YAML string for display * PERFORMANCE: Uses array join instead of string concatenation for better performance */ private formatMetadataAsYaml(baseMetadata: PortfolioElementMetadata, extendedMeta: any): string { const yamlLines: string[] = [ `name: ${baseMetadata.name}`, `description: ${baseMetadata.description}`, `author: ${baseMetadata.author}`, `version: ${baseMetadata.version}`, `created: ${baseMetadata.created}`, `updated: ${baseMetadata.updated}` ]; // Add optional fields if present if (extendedMeta.category) { yamlLines.push(`category: ${extendedMeta.category}`); } if (extendedMeta.triggers && Array.isArray(extendedMeta.triggers) && extendedMeta.triggers.length > 0) { yamlLines.push(`triggers: [${extendedMeta.triggers.join(', ')}]`); } if (extendedMeta.age_rating) { yamlLines.push(`age_rating: ${extendedMeta.age_rating}`); } if (extendedMeta.ai_generated !== undefined) { yamlLines.push(`ai_generated: ${extendedMeta.ai_generated}`); } if (extendedMeta.generation_method) { yamlLines.push(`generation_method: ${extendedMeta.generation_method}`); } if (extendedMeta.license) { yamlLines.push(`license: ${extendedMeta.license}`); } if (extendedMeta.tags && Array.isArray(extendedMeta.tags) && extendedMeta.tags.length > 0) { yamlLines.push(`tags: [${extendedMeta.tags.join(', ')}]`); } return yamlLines.join('\n'); } /** * Extracts metadata from an element file * Parses YAML frontmatter to get the actual metadata * SECURITY: Uses SecureYamlParser instead of yaml.load to prevent code execution (DMCP-SEC-005) * @param filePath Path to the element file * @returns Extracted metadata or null if parsing fails */ private async extractElementMetadata(filePath: string): Promise<Record<string, any> | null> { try { const content = await fs.readFile(filePath, 'utf-8'); // SECURITY FIX: Use SecureYamlParser to prevent YAML deserialization attacks // Previously would have used: yaml.load(yamlContent) which is vulnerable // Now: Uses SecureYamlParser.parse() which validates and sanitizes try { const parsed = SecureYamlParser.parse(content, { maxYamlSize: 64 * 1024, // 64KB limit for YAML validateContent: false, // Don't validate content field (just metadata) validateFields: false // Don't enforce persona-specific rules }); // Ensure we got an object back if (parsed.data && typeof parsed.data === 'object' && !Array.isArray(parsed.data)) { logger.debug('Extracted metadata from element file', { path: filePath, metadataKeys: Object.keys(parsed.data) }); return parsed.data; } logger.debug('Parsed data is not a valid metadata object', { path: filePath }); return null; } catch (parseError) { // SecureYamlParser throws on invalid YAML, try alternate frontmatter pattern // Handle files that might have frontmatter without full document structure const frontmatterMatch = content.match(/^---\r?\n([\s\S]*?)\r?\n---/); if (frontmatterMatch && frontmatterMatch[1]) { try { // Reconstruct full document for SecureYamlParser const fullContent = `---\n${frontmatterMatch[1]}\n---\n`; const parsed = SecureYamlParser.parse(fullContent, { maxYamlSize: 64 * 1024, validateContent: false, validateFields: false }); if (parsed.data && typeof parsed.data === 'object') { return parsed.data; } } catch (innerError) { logger.debug('Failed to parse frontmatter with SecureYamlParser', { path: filePath, error: innerError instanceof Error ? innerError.message : String(innerError) }); } } logger.debug('No valid frontmatter found in element file', { path: filePath }); return null; } } catch (error) { logger.warn('Failed to extract metadata from element file', { path: filePath, error: error instanceof Error ? error.message : String(error) }); return null; } } /** * Validates GitHub token and checks for expiration before usage * SECURITY ENHANCEMENT (Task #5): Token expiration validation to prevent stale token usage * @param token The GitHub token to validate * @returns Validation result with status and expiration info */ private async validateTokenBeforeUsage(token: string): Promise<{ isValid: boolean; isNearExpiry?: boolean; error?: SubmitToPortfolioResult; }> { try { // Check token format first (basic validation) if (!TokenManager.validateTokenFormat(token)) { SecurityMonitor.logSecurityEvent({ type: 'TOKEN_VALIDATION_FAILURE', severity: 'MEDIUM', source: 'SubmitToPortfolioTool.validateTokenBeforeUsage', details: 'Token has invalid format' }); return { isValid: false, error: { success: false, message: 'Invalid token format. Please re-authenticate.', error: 'INVALID_TOKEN_FORMAT' } }; } // Validate token with GitHub API to check expiration and permissions // NOTE: OAuth tokens use 'public_repo' scope, not 'repo' // Using centralized scope management for consistency const requiredScopes = TokenManager.getRequiredScopes('collection'); const validationResult = await TokenManager.validateTokenScopes(token, requiredScopes); if (!validationResult.isValid) { SecurityMonitor.logSecurityEvent({ type: 'TOKEN_VALIDATION_FAILURE', severity: 'MEDIUM', source: 'SubmitToPortfolioTool.validateTokenBeforeUsage', details: `Token validation failed: ${validationResult.error}` }); // Enhanced OAuth-specific error messages const tokenType = TokenManager.getTokenType(token); let errorCode: CollectionErrorCode; let enhancedDetails: string | undefined = validationResult.error; if (validationResult.error?.includes('Missing required scopes')) { errorCode = CollectionErrorCode.COLL_AUTH_002; // Provide OAuth-specific guidance if it's an OAuth token if (tokenType === 'OAuth Access Token') { enhancedDetails = `OAuth token missing 'public_repo' scope. Please re-authenticate with 'setup_github_auth' to get the correct scope.`; } } else { errorCode = CollectionErrorCode.COLL_AUTH_001; } return { isValid: false, error: { success: false, message: formatCollectionError(errorCode, 3, 5, enhancedDetails), error: errorCode } }; } // Check if token is near expiration (rate limit reset time can indicate token freshness) let isNearExpiry = false; if (validationResult.rateLimit?.resetTime) { const now = new Date(); const timeUntilReset = validationResult.rateLimit.resetTime.getTime() - now.getTime(); const oneHour = 60 * 60 * 1000; // Consider token "near expiry" if rate limit reset is more than 23 hours away // (GitHub rate limits reset every hour, so this suggests token age) if (timeUntilReset > 23 * oneHour) { isNearExpiry = true; logger.warn('GitHub token may be near expiration', { tokenPrefix: TokenManager.getTokenPrefix(token), rateLimitResetTime: validationResult.rateLimit.resetTime, recommendation: 'Consider re-authenticating for long operations' }); } } // Log successful validation SecurityMonitor.logSecurityEvent({ type: 'TOKEN_VALIDATION_SUCCESS', severity: 'LOW', source: 'SubmitToPortfolioTool.validateTokenBeforeUsage', details: 'GitHub token validated successfully before usage', metadata: { tokenType: TokenManager.getTokenType(token), scopes: validationResult.scopes, rateLimitRemaining: validationResult.rateLimit?.remaining, isNearExpiry } }); return { isValid: true, isNearExpiry }; } catch (error: any) { // Handle rate limit exceeded specifically if (error?.code === 'RATE_LIMIT_EXCEEDED') { logger.warn('Token validation rate limited, allowing operation to proceed with cached status'); // Still allow operation but log with COLL_API_001 SecurityMonitor.logSecurityEvent({ type: 'TOKEN_VALIDATION_SUCCESS', severity: 'LOW', source: 'SubmitToPortfolioTool.validateTokenBeforeUsage', details: 'Token validation rate limited but proceeding with cached status' }); return { isValid: true }; // Allow to proceed if rate limited, as basic format check passed } SecurityMonitor.logSecurityEvent({ type: 'TOKEN_VALIDATION_FAILURE', severity: 'HIGH', source: 'SubmitToPortfolioTool.validateTokenBeforeUsage', details: `Token validation error: ${error.message || 'unknown error'}` }); return { isValid: false, error: { success: false, message: 'Unable to validate GitHub token. Please check your connection and try again.', error: 'TOKEN_VALIDATION_ERROR' } }; } } /** * Enhanced path validation for portfolio operations with comprehensive security checks * SECURITY ENHANCEMENT (Task #7): Additional validation for special characters and malicious patterns * @param filePath The file path to validate * @returns Validation result with secure path or error response */ private async validatePortfolioPath(filePath: string): Promise<{ isValid: boolean; safePath?: string; error?: SubmitToPortfolioResult; }> { try { // Basic null/undefined check if (!filePath || typeof filePath !== 'string') { SecurityMonitor.logSecurityEvent({ type: 'PATH_TRAVERSAL_ATTEMPT', severity: 'MEDIUM', source: 'SubmitToPortfolioTool.validatePortfolioPath', details: 'Invalid path provided - null, undefined, or non-string' }); return { isValid: false, error: { success: false, message: 'Invalid file path provided', error: 'INVALID_PATH' } }; } // Check for suspicious patterns that could indicate path traversal or injection const suspiciousPatterns = [ /\.\./, // Path traversal /\/\.\./, // Unix path traversal /\\\.\./, // Windows path traversal /\u0000/, // NOSONAR - Null bytes detection for security /[\u0001-\u001f\u007f-\u009f]/, // NOSONAR - Control characters detection for security /[<>:"|?*]/, // Invalid filename characters on Windows /^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])$/i, // Reserved Windows names /^\./, // Hidden files (starting with dot) /\s+$/, // Trailing whitespace /^[\s]*$/, // Only whitespace /%[0-9a-fA-F]{2}/, // URL encoding (potential bypass attempt) /\\x[0-9a-fA-F]{2}/, // Hex encoding /\$\{.*\}/, // Template literal injection /`.*`/, // Backtick injection /[\\\/]{2,}/ // Multiple consecutive slashes ]; for (const pattern of suspiciousPatterns) { if (pattern.test(filePath)) { SecurityMonitor.logSecurityEvent({ type: 'PATH_TRAVERSAL_ATTEMPT', severity: 'HIGH', source: 'SubmitToPortfolioTool.validatePortfolioPath', details: `Suspicious pattern detected in file path: ${pattern.source}`, metadata: { pathLength: filePath.length, pattern: pattern.source } }); return { isValid: false, error: { success: false, message: 'File path contains invalid or suspicious characters', error: 'SUSPICIOUS_PATH_PATTERN' } }; } } // Check path length (prevent buffer overflow attempts) const MAX_PATH_LENGTH = process.platform === 'win32' ? 260 : 4096; if (filePath.length > MAX_PATH_LENGTH) { SecurityMonitor.logSecurityEvent({ type: 'PATH_TRAVERSAL_ATTEMPT', severity: 'MEDIUM', source: 'SubmitToPortfolioTool.validatePortfolioPath', details: `File path exceeds maximum length: ${filePath.length} > ${MAX_PATH_LENGTH}` }); return { isValid: false, error: { success: false, message: 'File path is too long', error: 'PATH_TOO_LONG' } }; } // Normalize path to resolve any relative components safely let normalizedPath: string; try { // Remove null bytes and normalize const cleanPath = filePath.replaceAll(/\u0000/g, ''); // NOSONAR - Removing null bytes for security normalizedPath = path.normalize(cleanPath); // Check if path is within the portfolio directory const portfolioManager = PortfolioManager.getInstance(); const portfolioBase = portfolioManager.getBaseDir(); // For absolute paths, verify they're within the portfolio directory if (path.isAbsolute(normalizedPath)) { const resolvedPath = path.resolve(normalizedPath); const resolvedBase = path.resolve(portfolioBase); // Path must be within the portfolio directory if (!resolvedPath.startsWith(resolvedBase)) { throw new Error('Path is outside portfolio directory'); } } else if (normalizedPath.includes('..')) { // Relative paths with .. are not allowed throw new Error('Path contains directory traversal'); } } catch (error) { SecurityMonitor.logSecurityEvent({ type: 'PATH_TRAVERSAL_ATTEMPT', severity: 'HIGH', source: 'SubmitToPortfolioTool.validatePortfolioPath', details: `Path normalization failed: ${error instanceof Error ? error.message : 'unknown error'}` }); return { isValid: false, error: { success: false, message: 'File path could not be safely processed', error: 'PATH_NORMALIZATION_FAILED' } }; } // Validate file extension (only allow safe extensions for portfolio content) const allowedExtensions = ['.md', '.markdown', '.txt', '.yml', '.yaml', '.json']; const fileExtension = path.extname(normalizedPath).toLowerCase(); if (fileExtension && !allowedExtensions.includes(fileExtension)) { SecurityMonitor.logSecurityEvent({ type: 'CONTENT_INJECTION_ATTEMPT', severity: 'MEDIUM', source: 'SubmitToPortfolioTool.validatePortfolioPath', details: `Disallowed file extension: ${fileExtension}`, metadata: { allowedExtensions: allowedExtensions.join(', ') } }); return { isValid: false, error: { success: false, message: `File extension '${fileExtension}' is not allowed. Allowed extensions: ${allowedExtensions.join(', ')}`, error: 'INVALID_FILE_EXTENSION' } }; } // Validate filename characters (only allow safe characters) const basename = path.basename(normalizedPath); const safeFilenamePattern = /^[a-zA-Z0-9\-_.\s()[\]{}]+$/; if (basename && !safeFilenamePattern.test(basename)) { SecurityMonitor.logSecurityEvent({ type: 'CONTENT_INJECTION_ATTEMPT', severity: 'MEDIUM', source: 'SubmitToPortfolioTool.validatePortfolioPath', details: 'Filename contains potentially dangerous characters', metadata: { filename: basename, allowedPattern: safeFilenamePattern.source } }); return { isValid: false, error: { success: false, message: 'Filename contains invalid characters. Only letters, numbers, spaces, hyphens, underscores, dots, and common brackets are allowed.', error: 'INVALID_FILENAME_CHARACTERS' } }; } // Log successful validation with correct event type for path validation // Fixed: Was using TOKEN_VALIDATION_SUCCESS which is semantically incorrect for path validation SecurityMonitor.logSecurityEvent({ type: 'PATH_VALIDATION_SUCCESS', severity: 'LOW', source: 'SubmitToPortfolioTool.validatePortfolioPath', details: 'File path validation successful', metadata: { originalPathLength: filePath.length, normalizedPathLength: normalizedPath.length, fileExtension: fileExtension || 'none' } }); return { isValid: true, safePath: normalizedPath }; } catch (error) { SecurityMonitor.logSecurityEvent({ type: 'PATH_TRAVERSAL_ATTEMPT', severity: 'HIGH', source: 'SubmitToPortfolioTool.validatePortfolioPath', details: `Path validation error: ${error instanceof Error ? error.message : 'unknown error'}` }); return { isValid: false, error: { success: false, message: 'Unable to validate file path. Please check the file path and try again.', error: 'PATH_VALIDATION_ERROR' } }; } } /** * Smart token management for long operations with refresh-like capabilities * SECURITY ENHANCEMENT (Task #14): Token refresh logic for long operations * * Note: GitHub OAuth device flow tokens don't have traditional refresh tokens, * but we can implement smart validation and guidance for long operations * * @param operationType Type of operation being performed * @returns Token management result with recommendations */ private async manageTokenForLongOperation(operationType: 'portfolio_creation' | 'collection_submission' | 'file_upload'): Promise<{ canProceed: boolean; token?: string; refreshRecommended?: boolean; error?: SubmitToPortfolioResult; }> { try { // Get current token const token = await TokenManager.getGitHubTokenAsync(); if (!token) { return { canProceed: false, error: { success: false, message: 'No GitHub token available. Please authenticate first.', error: 'NO_TOKEN' } }; } // Validate token for the specific operation const validation = await this.validateTokenBeforeUsage(token); if (!validation.isValid) { return { canProceed: false, error: validation.error }; } // Check if this is a long operation that might benefit from fresh authentication const longOperations = ['portfolio_creation', 'collection_submission']; const isLongOperation = longOperations.includes(operationType); // Get token type to determine refresh capabilities const tokenType = TokenManager.getTokenType(token); let refreshRecommended = false; // For long operations, check token age and recommend refresh if needed if (isLongOperation && validation.isNearExpiry) { refreshRecommended = true; SecurityMonitor.logSecurityEvent({ type: 'TOKEN_VALIDATION_SUCCESS', severity: 'LOW', source: 'SubmitToPortfolioTool.manageTokenForLongOperation', details: 'Long operation detected with aging token - refresh recommended', metadata: { operationType, tokenType, refreshRecommended: true } }); logger.warn('Long operation with potentially aging token detected', { operationType, tokenType, recommendation: 'Consider re-authenticating if operation fails' }); } // For OAuth tokens in long operations, we can provide guidance if (tokenType === 'OAuth Access Token' && isLongOperation) { logger.info('OAuth token detected for long operation', { operationType, tokenType, guidance: 'OAuth tokens are time-limited. If operation fails, re-authenticate using setup_github_auth' }); } // Log successful token management SecurityMonitor.logSecurityEvent({ type: 'TOKEN_VALIDATION_SUCCESS', severity: 'LOW', source: 'SubmitToPortfolioTool.manageTokenForLongOperation', details: 'Token management successful for long operation', metadata: { operationType, tokenType, isLongOperation, refreshRecommended } }); return { canProceed: true, token, refreshRecommended }; } catch (error: any) { SecurityMonitor.logSecurityEvent({ type: 'TOKEN_VALIDATION_FAILURE', severity: 'MEDIUM', source: 'SubmitToPortfolioTool.manageTokenForLongOperation', details: `Token management error: ${error.message || 'unknown error'}` }); return { canProceed: false, error: { success: false, message: 'Unable to manage token for operation. Please check your authentication and try again.', error: 'TOKEN_MANAGEMENT_ERROR' } }; } } /** * Provides user guidance for token refresh when operations fail due to token issues * SECURITY ENHANCEMENT (Task #14): User guidance for authentication refresh */ private formatTokenRefreshGuidance(operationType: string, tokenType: string): string { let guidance = '\n\nšŸ”„ **Token Refresh Guidance**:\n'; if (tokenType === 'OAuth Access Token') { guidance += '• Your OAuth token may have expired\n'; guidance += '• Run `setup_github_auth` to authenticate again\n'; guidance += '• This will generate a fresh token for continued access\n'; } else if (tokenType === 'Personal Access Token') { guidance += '• Your Personal Access Token may have expired\n'; guidance += '• Check your GitHub settings: https://github.com/settings/tokens\n'; guidance += '• Generate a new token if needed and update GITHUB_TOKEN environment variable\n'; } else { guidance += '• Your GitHub token may have expired or been revoked\n'; guidance += '• Re-authenticate using `setup_github_auth`\n'; guidance += '• Ensure your token has the required permissions\n'; } guidance += `\n**Operation**: ${operationType}\n`; guidance += '**Required scopes**: repo, user:email\n\n'; guidance += 'šŸ’” **Tip**: Fresh tokens work better for complex operations like portfolio creation.'; return guidance; } /** * Sets up GitHub repository access and ensures portfolio repository exists * @param authStatus Authentication status containing username * @returns Setup result or error response */ private async setupGitHubRepository(authStatus: any): Promise<{ success: boolean; error?: SubmitToPortfolioResult; }> { // SECURITY ENHANCEMENT (Task #14): Smart token management for long operations const tokenManagement = await this.manageTokenForLongOperation('portfolio_creation'); if (!tokenManagement.canProceed) { return { success: false, error: tokenManagement.error }; } const token = tokenManagement.token!; // Provide user guidance if refresh is recommended for this long operation if (tokenManagement.refreshRecommended) { const tokenType = TokenManager.getTokenType(token); const guidance = this.formatTokenRefreshGuidance('portfolio creation', tokenType); logger.warn(`Token refresh recommended for portfolio creation:${guidance}`); } this.portfolioManager.setToken(token); // Check if portfolio exists and create if needed const username = authStatus.username || 'unknown'; const portfolioExists = await this.portfolioManager.checkPortfolioExists(username); if (!portfolioExists) { logger.info('Creating portfolio repository...'); // Request consent for portfolio creation const repoUrl = await this.portfolioManager.createPortfolio(username, true); if (!repoUrl) { return { success: false, error: { success: false, message: 'Failed to create portfolio repository', error: 'CREATE_FAILED' } }; } } return { success: true }; } /** * Check if content already exists in the portfolio repository * Prevents duplicate uploads by comparing content hashes * @param repoFullName GitHub repository full name (owner/repo) * @param filePath Path to the file in the repository * @param content Content to check against existing * @returns true if identical content exists, false otherwise */ private async checkExistingContent( repoFullName: string, filePath: string, content: string, token: string ): Promise<boolean> { try { // Attempt to fetch existing file from GitHub const url = `https://api.github.com/repos/${repoFullName}/contents/${filePath}`; const response = await fetch(url, { headers: { 'Accept': 'application/vnd.github.v3+json', 'Authorization': `Bearer ${token}`, 'User-Agent': 'DollhouseMCP/1.0' } }); if (response.status === 404) { // File doesn't exist, not a duplicate return false; } if (!response.ok) { logger.warn('Failed to check existing content', { status: response.status, path: filePath }); // On error, allow upload to proceed return false; } const data = await response.json(); // GitHub returns content as base64 const existingContent = Buffer.from(data.content, 'base64').toString('utf-8'); // Compare content hashes const existingHash = createHash('sha256').update(existingContent).digest('hex'); const newHash = createHash('sha256').update(content).digest('hex'); const isDuplicate = existingHash === newHash; if (isDuplicate) { logger.info('Duplicate content detected, skipping upload', { path: filePath, hash: newHash.substring(0, 8) // Log partial hash for debugging }); } return isDuplicate; } catch (error) { logger.warn('Error checking for existing content', { error: error instanceof Error ? error.message : String(error), path: filePath }); // On error, allow upload to proceed rather than blocking return false; } } /** * Check if an issue for this content already exists in the collection * @param elementName Name of the element to check * @param username User submitting the element * @param token GitHub token for API access * @returns URL of existing issue if found, null otherwise */ private async checkExistingIssue( elementName: string, username: string, token: string ): Promise<string | null> { try { // Search for existing issues with this element name // Using both title and author to ensure it's the same submission const query = `repo:DollhouseMCP/collection is:issue "${elementName}" in:title author:${username}`; const url = `https://api.github.com/search/issues?q=${encodeURIComponent(query)}&sort=created&order=desc`; const response = await fetch(url, { headers: { 'Accept': 'application/vnd.github.v3+json', 'Authorization': `Bearer ${token}`, 'User-Agent': 'DollhouseMCP/1.0' } }); if (!response.ok) { logger.warn('Failed to search for existing issues', { status: response.status }); return null; } const data = await response.json(); if (data.items && data.items.length > 0) { // Found existing issue(s) const existingIssue = data.items[0]; logger.info('Found existing collection issue', { issueUrl: existingIssue.html_url, elementName }); return existingIssue.html_url; } return null; } catch (error) { logger.warn('Error checking for existing collection issue', { error: error instanceof Error ? error.message : String(error), elementName }); // On error, allow submission to proceed return null; } } /** * Submits element to portfolio and handles the complete response workflow * @param safeName The normalized name of the element * @param elementType The type of the element * @param metadata The metadata for the element * @param content The content of the element * @param authStatus Authentication status containing username and token * @returns Complete submission result with success message or error */ private async submitElementAndHandleResponse( safeName: string, elementType: ElementType, metadata: PortfolioElementMetadata, content: string, authStatus: any, localPath?: string // Local file path for collection submission ): Promise<SubmitToPortfolioResult> { // DUPLICATE DETECTION: Check if content already exists in portfolio const repoFullName = `${authStatus.username}/${this.portfolioManager.getRepositoryName()}`; const filePath = `${elementType}/${safeName}.md`; // Prepare the full content that would be saved const fullContent = `---\n${JSON.stringify(metadata, null, 2)}\n---\n\n${content}`; const isDuplicate = await this.checkExistingContent( repoFullName, filePath, fullContent, authStatus.token ); let fileUrl: string | null = null; if (isDuplicate) { // Content already exists, construct the URL without uploading fileUrl = `https://github.com/${repoFullName}/blob/main/${filePath}`; logger.info('Skipped duplicate upload to portfolio', { elementName: safeName, path: filePath }); } else { // Create element structure to save const element: PortfolioElement = { type: elementType, metadata, content }; // TYPE SAFETY FIX #2: Use adapter pattern instead of complex type casting // Previously: element as unknown as Parameters<typeof this.portfolioManager.saveElement>[0] // Now: Clean adapter pattern that implements IElement interface properly const adapter = new PortfolioElementAdapter(element); // UX IMPROVEMENT: Add retry logic for transient failures fileUrl = await this.saveElementWithRetry(adapter, safeName, elementType); } if (!fileUrl) { return { success: false, message: 'Failed to save element to GitHub portfolio after multiple attempts.\n\n' + 'šŸ’” **Troubleshooting Tips**:\n' + '• Check your GitHub authentication: `gh auth status`\n' + '• Verify repository permissions\n' + '• Try again in a few minutes (GitHub API rate limits)\n' + '• Check GitHub status: https://status.github.com', error: 'SAVE_FAILED' }; } // Log submission result (DMCP-SEC-006) // Check if this was a duplicate skip or actual upload const wasDuplicate = fileUrl && fileUrl.includes('blob/main/') && !fileUrl.includes('/commit/'); if (wasDuplicate) { logger.info(`Skipped duplicate upload for ${safeName} - already in portfolio`, { elementType, username: authStatus.username, fileUrl }); } else { logger.info(`Successfully submitted ${safeName} to GitHub portfolio`, { elementType, username: authStatus.username, fileUrl }); } // SECURITY ENHANCEMENT (Task #14): Smart token management for collection submission const collectionTokenManagement = await this.manageTokenForLongOperation('collection_submission'); if (!collectionTokenManagement.canProceed) { // Token management failed for collection submission, but main submission succeeded const errorMessage = collectionTokenManagement.error?.message || 'Token management failed'; const portfolioMessage = wasDuplicate ? `āœ… ${safeName} already exists in your GitHub portfolio (no changes needed)` : `āœ… Successfully uploaded ${safeName} to your GitHub portfolio!`; return { success: true, message: `${portfolioMessage}\nšŸ“ Portfolio URL: ${fileUrl}\n\nāš ļø Collection submission skipped: ${errorMessage}`, url: fileUrl }; } const token = collectionTokenManagement.token!; // Provide refresh guidance if recommended for collection submission if (collectionTokenManagement.refreshRecommended) { const tokenType = TokenManager.getTokenType(token); logger.info('Collection submission proceeding with aging token', { tokenType, recommendation: 'If collection submission fails, try re-authenticating with setup_github_auth' }); } // ENHANCEMENT (Issue #549): Ask user if they want to submit to collection // This completes the community contribution workflow const collectionSubmissionResult = await this.promptForCollectionSubmission({ elementName: safeName, elementType, portfolioUrl: fileUrl, username: authStatus.username || 'unknown', metadata, token, localPath // Pass the local file path for reading content }); // Build the response message based on what happened const portfolioMessage = wasDuplicate ? `āœ… ${safeName} already exists in your GitHub portfolio (no changes needed)` : `āœ… Successfully uploaded ${safeName} to your GitHub portfolio!`; let message = `${portfolioMessage}\n`; message += `šŸ“ Portfolio URL: ${fileUrl}\n\n`; if (collectionSubmissionResult.submitted) { if (collectionSubmissionResult.isDuplicate) { message += `šŸ“‹ Collection issue already exists (no new submission needed)\n`; message += `šŸ”— Issue: ${collectionSubmissionResult.issueUrl}`; } else { message += `šŸŽ‰ Also submitted to DollhouseMCP collection for community review!\n`; message += `šŸ“‹ Issue: ${collectionSubmissionResult.issueUrl}`; } } else if (collectionSubmissionResult.declined) { message += `šŸ’” You can submit to the collection later using the same command.`; } else if (collectionSubmissionResult.error) { message += `āš ļø Collection submission failed: ${collectionSubmissionResult.error}\n`;
  • Input validation helper: Unicode normalization/security check, safe name extraction.
    private async validateAndNormalizeParams(params: SubmitToPortfolioParams): Promise<{ success: boolean; safeName?: string; error?: SubmitToPortfolioResult; }> { // Normalize user input to prevent Unicode attacks (DMCP-SEC-004) const normalizedName = UnicodeValidator.normalize(params.name); if (!normalizedName.isValid) { SecurityMonitor.logSecurityEvent({ type: 'UNICODE_VALIDATION_ERROR', severity: 'MEDIUM', source: 'SubmitToPortfolioTool.execute', details: `Invalid Unicode in element name: ${normalizedName.detectedIssues?.[0] || 'unknown error'}` }); return { success: false, error: { success: false, message: `Invalid characters in element name: ${normalizedName.detectedIssues?.[0] || 'unknown error'}`, error: 'INVALID_INPUT' } }; } return { success: true, safeName: normalizedName.normalizedContent }; } /** * Checks if the user is authenticated with GitHub * @returns Authentication check result with status or error response */ private async checkAuthentication(): Promise<{ success: boolean; authStatus?: any; error?: SubmitToPortfolioResult; }> { const authStatus = await this.authManager.getAuthStatus(); if (!authStatus.isAuthenticated) { // Log authentication required (using existing event type) logger.warn('User attempted portfolio submission without authentication'); return { success: false, error: { success: false, message: 'Not authenticated. Please authenticate first using the GitHub OAuth flow.\n\n' + 'Visit: https://docs.anthropic.com/en/docs/claude-code/oauth-setup\n' + 'Or run: gh auth login --web', error: 'NOT_AUTHENTICATED' } }; } return { success: true, authStatus }; } /** * Discovers content locally with smart type detection * @param safeName The normalized name to search for * @param explicitType Optional explicit element type provided by user * @param originalName Original user-provided name for error messages * @returns Content discovery result with element type and path or error response */ private async discoverContentWithTypeDetection( safeName: string, explicitType?: ElementType, originalName?: string ): Promise<{ success: boolean; elementType?: ElementType; localPath?: string; error?: SubmitToPortfolioResult; }> { let elementType = explicitType; let localPath: string | null = null; if (elementType) { // Type explicitly provided - search in that specific directory only localPath = await this.findLocalContent(safeName, elementType); if (!localPath) { // UX IMPROVEMENT: Provide helpful suggestions for finding content const portfolioManager = PortfolioManager.getInstance(); const elementDir = portfolioManager.getElementDir(elementType); return { success: false, error: { success: false, message: `Could not find ${elementType} named "${originalName || safeName}" in local portfolio.\n\n` + `**Searched in**: ${elementDir}\n\n` + `**Troubleshooting Tips**:\n` + `• Check if the file exists using your file explorer\n` + `• Try using the exact filename (without extension)\n` + `• Use \`list_portfolio\` to see all available ${elementType}\n` + `• If unsure of the type, omit --type and let the system detect it\n\n` + `**Common name formats that work**:\n` + `• "my-element" (kebab-case)\n` + `• "My Element" (with spaces)\n` + `• "MyElement" (PascalCase)\n` + `• Partial matches are supported`, error: 'CONTENT_NOT_FOUND' } }; } } else { // CRITICAL FIX: No type provided - implement smart detection across ALL element types // This prevents the previous hardcoded default to PERSONA and enables proper type detection const detectionResult = await this.detectElementType(safeName); if (!detectionResult.found) { // UX IMPROVEMENT: Enhanced guidance with specific suggestions const availableTypes = Object.values(ElementType).join(', '); // Get suggestions for similar names const suggestions = await this.generateNameSuggestions(safeName); let message = `Content "${originalName || safeName}" not found in portfolio.\n\n`; message += `šŸ” **Searched in all element types**: ${availableTypes}\n\n`; if (suggestions.length > 0) { message += `šŸ’” **Did you mean one of these?**\n`; for (const suggestion of suggestions.slice(0, SEARCH_CONFIG.MAX_SUGGESTIONS)) { message += ` • "${suggestion.name}" (${suggestion.type})\n`; } message += `\n`; } message += `šŸ› ļø **Troubleshooting Steps**:\n`; message += `1. šŸ“ Use \`list_portfolio\` to see all available content\n`; message += `2. šŸ” Check exact spelling and try variations:\n`; message += ` • "${(originalName || safeName).toLowerCase()}" (lowercase)\n`; message += ` • "${(originalName || safeName).replaceAll(/[^a-z0-9]/gi, '-').toLowerCase()}" (normalized)\n`; if ((originalName || safeName).includes('.')) { message += ` • "${(originalName || safeName).replaceAll('.', '')}" (no dots)\n`; } message += `3. šŸŽÆ Specify element type: \`submit_collection_content "${originalName || safeName}" --type=personas\`\n`; message += `4. šŸ“ Check if file exists in portfolio directories\n\n`; message += `šŸ“ **Tip**: The system searches filenames AND metadata names with fuzzy matching.`; return { success: false, error: { success: false, message, error: 'CONTENT_NOT_FOUND' } }; } if (detectionResult.matches.length > 1) { // Multiple matches found - ask user to specify type const matchDetails = detectionResult.matches.map(m => `- ${m.type}: ${m.path}`).join('\n'); return { success: false, error: { success: false, message: `Content "${originalName || safeName}" found in multiple element types:\n\n${matchDetails}\n\n` + `Please specify the element type using the --type parameter to avoid ambiguity.`, error: 'MULTIPLE_MATCHES_FOUND' } }; } // Single match found - use it const match = detectionResult.matches[0]; elementType = match.type; localPath = match.path; logger.info(`Smart detection: Found "${safeName}" as ${elementType}`, { name: safeName, detectedType: elementType, path: localPath }); } return { success: true, elementType, localPath }; } /** * Validates file size and content security before processing * @param localPath Path to the local file to validate * @returns Validation result with content or error response */ private async validateFileAndContent(localPath: string): Promise<{ success: boolean; content?: string; error?: SubmitToPortfolioResult; }> { // SECURITY ENHANCEMENT (Task #7): Validate file path before processing const pathValidation = await this.validatePortfolioPath(localPath); if (!pathValidation.isValid) { return { success: false, error: pathValidation.error }; } // Use the validated safe path for all subsequent operations const safePath = pathValidation.safePath!; // Validate file size before reading const stats = await fs.stat(safePath); if (stats.size > FILE_SIZE_LIMITS.MAX_FILE_SIZE) { SecurityMonitor.logSecurityEvent({ type: 'RATE_LIMIT_EXCEEDED', severity: 'MEDIUM', source: 'SubmitToPortfolioTool.execute', details: `File size ${stats.size} exceeds limit of ${FILE_SIZE_LIMITS.MAX_FILE_SIZE}` }); return { success: false, error: { success: false, message: `File size exceeds ${FILE_SIZE_LIMITS.MAX_FILE_SIZE_MB}MB limit`, error: 'FILE_TOO_LARGE' } }; } // Validate content security const content = await fs.readFile(safePath, 'utf-8'); const validationResult = ContentValidator.validateAndSanitize(content); if (!validationResult.isValid && validationResult.severity === 'critical') { SecurityMonitor.logSecurityEvent({ type: 'CONTENT_INJECTION_ATTEMPT', severity: 'HIGH', source: 'SubmitToPortfolioTool.execute', details: `Critical security issues detected: ${validationResult.detectedPatterns?.join(', ')}` }); return { success: false, error: { success: false, message: `Content validation failed: ${validationResult.detectedPatterns?.join(', ')}`, error: 'VALIDATION_FAILED' } }; } return { success: true, content }; } /** * Prepares metadata for the portfolio element * @param safeName The normalized name of the element * @param elementType The type of the element * @param authStatus Authentication status containing username * @returns Metadata object for the element */ private async prepareElementMetadata( safeName: string, elementType: ElementType, authStatus: any, filePath?: string ): Promise<PortfolioElementMetadata> { // Try to extract metadata from the file if path is provided let fileMetadata: Record<string, any> | null = null; if (filePath) { fileMetadata = await this.extractElementMetadata(filePath); } // TYPE SAFETY: Define extended metadata interface for better type safety interface ExtendedMetadata extends PortfolioElementMetadata { triggers?: string[]; category?: string; age_rating?: string; ai_generated?: boolean; generation_method?: string; license?: string; tags?: string[]; } // Build metadata with real values from file, falling back to defaults const metadata: ExtendedMetadata = { name: safeName, description: fileMetadata?.description || fileMetadata?.summary || `${elementType} submitted from local portfolio`, author: authStatus.username || fileMetadata?.author || 'unknown', created: fileMetadata?.created || fileMetadata?.created_date || new Date().toISOString(), updated: fileMetadata?.updated || fileMetadata?.modified || new Date().toISOString(), version: fileMetadata?.version || '1.0.0' }; // Add additional metadata fields if present (with type safety) if (fileMetadata) { // Preserve other metadata fields that might be useful if (fileMetadata.triggers && Array.isArray(fileMetadata.triggers)) { metadata.triggers = fileMetadata.triggers; } if (fileMetadata.category && typeof fileMetadata.category === 'string') { metadata.category = fileMetadata.category; } if (fileMetadata.age_rating && typeof fileMetadata.age_rating === 'string') { metadata.age_rating = fileMetadata.age_rating; } if (fileMetadata.ai_generated !== undefined) { metadata.ai_generated = Boolean(fileMetadata.ai_generated); } if (fileMetadata.generation_method && typeof fileMetadata.generation_method === 'string') { metadata.generation_method = fileMetadata.generation_method; } if (fileMetadata.license && typeof fileMetadata.license === 'string') { metadata.license = fileMetadata.license; } if (fileMetadata.tags && Array.isArray(fileMetadata.tags)) { metadata.tags = fileMetadata.tags; } } logger.info('Prepared element metadata', { elementName: safeName, hasFileMetadata: !!fileMetadata, description: metadata.description.substring(0, 100) // Log first 100 chars }); return metadata; } /** * Formats metadata as YAML string for display * PERFORMANCE: Uses array join instead of string concatenation for better performance */ private formatMetadataAsYaml(baseMetadata: PortfolioElementMetadata, extendedMeta: any): string { const yamlLines: string[] = [ `name: ${baseMetadata.name}`, `description: ${baseMetadata.description}`, `author: ${baseMetadata.author}`, `version: ${baseMetadata.version}`, `created: ${baseMetadata.created}`, `updated: ${baseMetadata.updated}` ]; // Add optional fields if present if (extendedMeta.category) { yamlLines.push(`category: ${extendedMeta.category}`); } if (extendedMeta.triggers && Array.isArray(extendedMeta.triggers) && extendedMeta.triggers.length > 0) { yamlLines.push(`triggers: [${extendedMeta.triggers.join(', ')}]`); } if (extendedMeta.age_rating) { yamlLines.push(`age_rating: ${extendedMeta.age_rating}`); } if (extendedMeta.ai_generated !== undefined) { yamlLines.push(`ai_generated: ${extendedMeta.ai_generated}`); } if (extendedMeta.generation_method) { yamlLines.push(`generation_method: ${extendedMeta.generation_method}`); } if (extendedMeta.license) { yamlLines.push(`license: ${extendedMeta.license}`); } if (extendedMeta.tags && Array.isArray(extendedMeta.tags) && extendedMeta.tags.length > 0) { yamlLines.push(`tags: [${extendedMeta.tags.join(', ')}]`); } return yamlLines.join('\n'); } /** * Extracts metadata from an element file * Parses YAML frontmatter to get the actual metadata * SECURITY: Uses SecureYamlParser instead of yaml.load to prevent code execution (DMCP-SEC-005) * @param filePath Path to the element file * @returns Extracted metadata or null if parsing fails */ private async extractElementMetadata(filePath: string): Promise<Record<string, any> | null> { try { const content = await fs.readFile(filePath, 'utf-8'); // SECURITY FIX: Use SecureYamlParser to prevent YAML deserialization attacks // Previously would have used: yaml.load(yamlContent) which is vulnerable // Now: Uses SecureYamlParser.parse() which validates and sanitizes try { const parsed = SecureYamlParser.parse(content, { maxYamlSize: 64 * 1024, // 64KB limit for YAML validateContent: false, // Don't validate content field (just metadata) validateFields: false // Don't enforce persona-specific rules }); // Ensure we got an object back if (parsed.data && typeof parsed.data === 'object' && !Array.isArray(parsed.data)) { logger.debug('Extracted metadata from element file', { path: filePath, metadataKeys: Object.keys(parsed.data) }); return parsed.data; } logger.debug('Parsed data is not a valid metadata object', { path: filePath }); return null; } catch (parseError) { // SecureYamlParser throws on invalid YAML, try alternate frontmatter pattern // Handle files that might have frontmatter without full document structure const frontmatterMatch = content.match(/^---\r?\n([\s\S]*?)\r?\n---/); if (frontmatterMatch && frontmatterMatch[1]) { try { // Reconstruct full document for SecureYamlParser const fullContent = `---\n${frontmatterMatch[1]}\n---\n`; const parsed = SecureYamlParser.parse(fullContent, { maxYamlSize: 64 * 1024, validateContent: false, validateFields: false }); if (parsed.data && typeof parsed.data === 'object') { return parsed.data; } } catch (innerError) { logger.debug('Failed to parse frontmatter with SecureYamlParser', { path: filePath, error: innerError instanceof Error ? innerError.message : String(innerError) }); } } logger.debug('No valid frontmatter found in element file', { path: filePath }); return null; } } catch (error) { logger.warn('Failed to extract metadata from element file', { path: filePath, error: error instanceof Error ? error.message : String(error) }); return null; } } /** * Validates GitHub token and checks for expiration before usage * SECURITY ENHANCEMENT (Task #5): Token expiration validation to prevent stale token usage * @param token The GitHub token to validate * @returns Validation result with status and expiration info */ private async validateTokenBeforeUsage(token: string): Promise<{ isValid: boolean; isNearExpiry?: boolean; error?: SubmitToPortfolioResult; }> { try { // Check token format first (basic validation) if (!TokenManager.validateTokenFormat(token)) { SecurityMonitor.logSecurityEvent({ type: 'TOKEN_VALIDATION_FAILURE', severity: 'MEDIUM', source: 'SubmitToPortfolioTool.validateTokenBeforeUsage', details: 'Token has invalid format' }); return { isValid: false, error: { success: false, message: 'Invalid token format. Please re-authenticate.', error: 'INVALID_TOKEN_FORMAT' } }; } // Validate token with GitHub API to check expiration and permissions // NOTE: OAuth tokens use 'public_repo' scope, not 'repo' // Using centralized scope management for consistency const requiredScopes = TokenManager.getRequiredScopes('collection'); const validationResult = await TokenManager.validateTokenScopes(token, requiredScopes); if (!validationResult.isValid) { SecurityMonitor.logSecurityEvent({ type: 'TOKEN_VALIDATION_FAILURE', severity: 'MEDIUM', source: 'SubmitToPortfolioTool.validateTokenBeforeUsage', details: `Token validation failed: ${validationResult.error}` }); // Enhanced OAuth-specific error messages const tokenType = TokenManager.getTokenType(token); let errorCode: CollectionErrorCode; let enhancedDetails: string | undefined = validationResult.error; if (validationResult.error?.includes('Missing required scopes')) { errorCode = CollectionErrorCode.COLL_AUTH_002; // Provide OAuth-specific guidance if it's an OAuth token if (tokenType === 'OAuth Access Token') { enhancedDetails = `OAuth token missing 'public_repo' scope. Please re-authenticate with 'setup_github_auth' to get the correct scope.`; } } else { errorCode = CollectionErrorCode.COLL_AUTH_001; } return { isValid: false, error: { success: false, message: formatCollectionError(errorCode, 3, 5, enhancedDetails), error: errorCode } }; } // Check if token is near expiration (rate limit reset time can indicate token freshness) let isNearExpiry = false; if (validationResult.rateLimit?.resetTime) { const now = new Date(); const timeUntilReset = validationResult.rateLimit.resetTime.getTime() - now.getTime(); const oneHour = 60 * 60 * 1000; // Consider token "near expiry" if rate limit reset is more than 23 hours away // (GitHub rate limits reset every hour, so this suggests token age) if (timeUntilReset > 23 * oneHour) { isNearExpiry = true; logger.warn('GitHub token may be near expiration', { tokenPrefix: TokenManager.getTokenPrefix(token), rateLimitResetTime: validationResult.rateLimit.resetTime, recommendation: 'Consider re-authenticating for long operations' }); } } // Log successful validation SecurityMonitor.logSecurityEvent({ type: 'TOKEN_VALIDATION_SUCCESS', severity: 'LOW', source: 'SubmitToPortfolioTool.validateTokenBeforeUsage', details: 'GitHub token validated successfully before usage', metadata: { tokenType: TokenManager.getTokenType(token), scopes: validationResult.scopes, rateLimitRemaining: validationResult.rateLimit?.remaining, isNearExpiry } }); return { isValid: true, isNearExpiry }; } catch (error: any) { // Handle rate limit exceeded specifically if (error?.code === 'RATE_LIMIT_EXCEEDED') { logger.warn('Token validation rate limited, allowing operation to proceed with cached status'); // Still allow operation but log with COLL_API_001 SecurityMonitor.logSecurityEvent({ type: 'TOKEN_VALIDATION_SUCCESS', severity: 'LOW', source: 'SubmitToPortfolioTool.validateTokenBeforeUsage', details: 'Token validation rate limited but proceeding with cached status' }); return { isValid: true }; // Allow to proceed if rate limited, as basic format check passed } SecurityMonitor.logSecurityEvent({ type: 'TOKEN_VALIDATION_FAILURE', severity: 'HIGH', source: 'SubmitToPortfolioTool.validateTokenBeforeUsage', details: `Token validation error: ${error.message || 'unknown error'}` }); return { isValid: false, error: { success: false, message: 'Unable to validate GitHub token. Please check your connection and try again.', error: 'TOKEN_VALIDATION_ERROR' } }; } } /** * Enhanced path validation for portfolio operations with comprehensive security checks * SECURITY ENHANCEMENT (Task #7): Additional validation for special characters and malicious patterns * @param filePath The file path to validate * @returns Validation result with secure path or error response */ private async validatePortfolioPath(filePath: string): Promise<{ isValid: boolean; safePath?: string; error?: SubmitToPortfolioResult; }> { try { // Basic null/undefined check if (!filePath || typeof filePath !== 'string') { SecurityMonitor.logSecurityEvent({ type: 'PATH_TRAVERSAL_ATTEMPT', severity: 'MEDIUM', source: 'SubmitToPortfolioTool.validatePortfolioPath', details: 'Invalid path provided - null, undefined, or non-string' }); return { isValid: false, error: { success: false, message: 'Invalid file path provided', error: 'INVALID_PATH' } }; } // Check for suspicious patterns that could indicate path traversal or injection const suspiciousPatterns = [ /\.\./, // Path traversal /\/\.\./, // Unix path traversal /\\\.\./, // Windows path traversal /\u0000/, // NOSONAR - Null bytes detection for security /[\u0001-\u001f\u007f-\u009f]/, // NOSONAR - Control characters detection for security /[<>:"|?*]/, // Invalid filename characters on Windows /^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])$/i, // Reserved Windows names /^\./, // Hidden files (starting with dot) /\s+$/, // Trailing whitespace /^[\s]*$/, // Only whitespace /%[0-9a-fA-F]{2}/, // URL encoding (potential bypass attempt) /\\x[0-9a-fA-F]{2}/, // Hex encoding /\$\{.*\}/, // Template literal injection /`.*`/, // Backtick injection /[\\\/]{2,}/ // Multiple consecutive slashes ]; for (const pattern of suspiciousPatterns) { if (pattern.test(filePath)) { SecurityMonitor.logSecurityEvent({ type: 'PATH_TRAVERSAL_ATTEMPT', severity: 'HIGH', source: 'SubmitToPortfolioTool.validatePortfolioPath', details: `Suspicious pattern detected in file path: ${pattern.source}`, metadata: { pathLength: filePath.length, pattern: pattern.source } }); return { isValid: false, error: { success: false, message: 'File path contains invalid or suspicious characters', error: 'SUSPICIOUS_PATH_PATTERN' } }; } } // Check path length (prevent buffer overflow attempts) const MAX_PATH_LENGTH = process.platform === 'win32' ? 260 : 4096; if (filePath.length > MAX_PATH_LENGTH) { SecurityMonitor.logSecurityEvent({ type: 'PATH_TRAVERSAL_ATTEMPT', severity: 'MEDIUM', source: 'SubmitToPortfolioTool.validatePortfolioPath', details: `File path exceeds maximum length: ${filePath.length} > ${MAX_PATH_LENGTH}` }); return { isValid: false, error: { success: false, message: 'File path is too long', error: 'PATH_TOO_LONG' } }; } // Normalize path to resolve any relative components safely let normalizedPath: string; try { // Remove null bytes and normalize const cleanPath = filePath.replaceAll(/\u0000/g, ''); // NOSONAR - Removing null bytes for security normalizedPath = path.normalize(cleanPath); // Check if path is within the portfolio directory const portfolioManager = PortfolioManager.getInstance(); const portfolioBase = portfolioManager.getBaseDir(); // For absolute paths, verify they're within the portfolio directory if (path.isAbsolute(normalizedPath)) { const resolvedPath = path.resolve(normalizedPath); const resolvedBase = path.resolve(portfolioBase); // Path must be within the portfolio directory if (!resolvedPath.startsWith(resolvedBase)) { throw new Error('Path is outside portfolio directory'); } } else if (normalizedPath.includes('..')) { // Relative paths with .. are not allowed throw new Error('Path contains directory traversal'); } } catch (error) { SecurityMonitor.logSecurityEvent({ type: 'PATH_TRAVERSAL_ATTEMPT', severity: 'HIGH', source: 'SubmitToPortfolioTool.validatePortfolioPath', details: `Path normalization failed: ${error instanceof Error ? error.message : 'unknown error'}` }); return { isValid: false, error: { success: false, message: 'File path could not be safely processed', error: 'PATH_NORMALIZATION_FAILED' } }; } // Validate file extension (only allow safe extensions for portfolio content) const allowedExtensions = ['.md', '.markdown', '.txt', '.yml', '.yaml', '.json']; const fileExtension = path.extname(normalizedPath).toLowerCase(); if (fileExtension && !allowedExtensions.includes(fileExtension)) { SecurityMonitor.logSecurityEvent({ type: 'CONTENT_INJECTION_ATTEMPT', severity: 'MEDIUM', source: 'SubmitToPortfolioTool.validatePortfolioPath', details: `Disallowed file extension: ${fileExtension}`, metadata: { allowedExtensions: allowedExtensions.join(', ') } }); return { isValid: false, error: { success: false, message: `File extension '${fileExtension}' is not allowed. Allowed extensions: ${allowedExtensions.join(', ')}`, error: 'INVALID_FILE_EXTENSION' } }; } // Validate filename characters (only allow safe characters) const basename = path.basename(normalizedPath); const safeFilenamePattern = /^[a-zA-Z0-9\-_.\s()[\]{}]+$/; if (basename && !safeFilenamePattern.test(basename)) { SecurityMonitor.logSecurityEvent({ type: 'CONTENT_INJECTION_ATTEMPT', severity: 'MEDIUM', source: 'SubmitToPortfolioTool.validatePortfolioPath', details: 'Filename contains potentially dangerous characters', metadata: { filename: basename, allowedPattern: safeFilenamePattern.source } }); return { isValid: false, error: { success: false, message: 'Filename contains invalid characters. Only letters, numbers, spaces, hyphens, underscores, dots, and common brackets are allowed.', error: 'INVALID_FILENAME_CHARACTERS' } }; } // Log successful validation with correct event type for path validation // Fixed: Was using TOKEN_VALIDATION_SUCCESS which is semantically incorrect for path validation SecurityMonitor.logSecurityEvent({ type: 'PATH_VALIDATION_SUCCESS', severity: 'LOW', source: 'SubmitToPortfolioTool.validatePortfolioPath', details: 'File path validation successful', metadata: { originalPathLength: filePath.length, normalizedPathLength: normalizedPath.length, fileExtension: fileExtension || 'none' } }); return { isValid: true, safePath: normalizedPath }; } catch (error) { SecurityMonitor.logSecurityEvent({ type: 'PATH_TRAVERSAL_ATTEMPT', severity: 'HIGH', source: 'SubmitToPortfolioTool.validatePortfolioPath', details: `Path validation error: ${error instanceof Error ? error.message : 'unknown error'}` }); return { isValid: false, error: { success: false, message: 'Unable to validate file path. Please check the file path and try again.', error: 'PATH_VALIDATION_ERROR' } }; } } /** * Smart token management for long operations with refresh-like capabilities * SECURITY ENHANCEMENT (Task #14): Token refresh logic for long operations * * Note: GitHub OAuth device flow tokens don't have traditional refresh tokens, * but we can implement smart validation and guidance for long operations * * @param operationType Type of operation being performed * @returns Token management result with recommendations */ private async manageTokenForLongOperation(operationType: 'portfolio_creation' | 'collection_submission' | 'file_upload'): Promise<{ canProceed: boolean; token?: string; refreshRecommended?: boolean; error?: SubmitToPortfolioResult; }> { try { // Get current token const token = await TokenManager.getGitHubTokenAsync(); if (!token) { return { canProceed: false, error: { success: false, message: 'No GitHub token available. Please authenticate first.', error: 'NO_TOKEN' } }; } // Validate token for the specific operation const validation = await this.validateTokenBeforeUsage(token); if (!validation.isValid) { return { canProceed: false, error: validation.error }; } // Check if this is a long operation that might benefit from fresh authentication const longOperations = ['portfolio_creation', 'collection_submission']; const isLongOperation = longOperations.includes(operationType); // Get token type to determine refresh capabilities const tokenType = TokenManager.getTokenType(token); let refreshRecommended = false; // For long operations, check token age and recommend refresh if needed if (isLongOperation && validation.isNearExpiry) { refreshRecommended = true; SecurityMonitor.logSecurityEvent({ type: 'TOKEN_VALIDATION_SUCCESS', severity: 'LOW', source: 'SubmitToPortfolioTool.manageTokenForLongOperation', details: 'Long operation detected with aging token - refresh recommended', metadata: { operationType, tokenType, refreshRecommended: true } }); logger.warn('Long operation with potentially aging token detected', { operationType, tokenType, recommendation: 'Consider re-authenticating if operation fails' }); } // For OAuth tokens in long operations, we can provide guidance if (tokenType === 'OAuth Access Token' && isLongOperation) { logger.info('OAuth token detected for long operation', { operationType, tokenType, guidance: 'OAuth tokens are time-limited. If operation fails, re-authenticate using setup_github_auth' }); } // Log successful token management SecurityMonitor.logSecurityEvent({ type: 'TOKEN_VALIDATION_SUCCESS', severity: 'LOW', source: 'SubmitToPortfolioTool.manageTokenForLongOperation', details: 'Token management successful for long operation', metadata: { operationType, tokenType, isLongOperation, refreshRecommended } }); return { canProceed: true, token, refreshRecommended }; } catch (error: any) { SecurityMonitor.logSecurityEvent({ type: 'TOKEN_VALIDATION_FAILURE', severity: 'MEDIUM', source: 'SubmitToPortfolioTool.manageTokenForLongOperation', details: `Token management error: ${error.message || 'unknown error'}` }); return { canProceed: false, error: { success: false, message: 'Unable to manage token for operation. Please check your authentication and try again.', error: 'TOKEN_MANAGEMENT_ERROR' } }; } } /** * Provides user guidance for token refresh when operations fail due to token issues * SECURITY ENHANCEMENT (Task #14): User guidance for authentication refresh */ private formatTokenRefreshGuidance(operationType: string, tokenType: string): string { let guidance = '\n\nšŸ”„ **Token Refresh Guidance**:\n'; if (tokenType === 'OAuth Access Token') { guidance += '• Your OAuth token may have expired\n'; guidance += '• Run `setup_github_auth` to authenticate again\n'; guidance += '• This will generate a fresh token for continued access\n'; } else if (tokenType === 'Personal Access Token') { guidance += '• Your Personal Access Token may have expired\n'; guidance += '• Check your GitHub settings: https://github.com/settings/tokens\n'; guidance += '• Generate a new token if needed and update GITHUB_TOKEN environment variable\n'; } else { guidance += '• Your GitHub token may have expired or been revoked\n'; guidance += '• Re-authenticate using `setup_github_auth`\n'; guidance += '• Ensure your token has the required permissions\n'; } guidance += `\n**Operation**: ${operationType}\n`; guidance += '**Required scopes**: repo, user:email\n\n'; guidance += 'šŸ’” **Tip**: Fresh tokens work better for complex operations like portfolio creation.'; return guidance; } /** * Sets up GitHub repository access and ensures portfolio repository exists * @param authStatus Authentication status containing username * @returns Setup result or error response */ private async setupGitHubRepository(authStatus: any): Promise<{ success: boolean; error?: SubmitToPortfolioResult; }> { // SECURITY ENHANCEMENT (Task #14): Smart token management for long operations const tokenManagement = await this.manageTokenForLongOperation('portfolio_creation'); if (!tokenManagement.canProceed) { return { success: false, error: tokenManagement.error }; } const token = tokenManagement.token!; // Provide user guidance if refresh is recommended for this long operation if (tokenManagement.refreshRecommended) { const tokenType = TokenManager.getTokenType(token); const guidance = this.formatTokenRefreshGuidance('portfolio creation', tokenType); logger.warn(`Token refresh recommended for portfolio creation:${guidance}`); } this.portfolioManager.setToken(token); // Check if portfolio exists and create if needed const username = authStatus.username || 'unknown'; const portfolioExists = await this.portfolioManager.checkPortfolioExists(username); if (!portfolioExists) { logger.info('Creating portfolio repository...'); // Request consent for portfolio creation const repoUrl = await this.portfolioManager.createPortfolio(username, true); if (!repoUrl) { return { success: false, error: { success: false, message: 'Failed to create portfolio repository', error: 'CREATE_FAILED' } }; } } return { success: true }; } /** * Check if content already exists in the portfolio repository * Prevents duplicate uploads by comparing content hashes * @param repoFullName GitHub repository full name (owner/repo) * @param filePath Path to the file in the repository * @param content Content to check against existing * @returns true if identical content exists, false otherwise */ private async checkExistingContent( repoFullName: string, filePath: string, content: string, token: string ): Promise<boolean> { try { // Attempt to fetch existing file from GitHub const url = `https://api.github.com/repos/${repoFullName}/contents/${filePath}`; const response = await fetch(url, { headers: { 'Accept': 'application/vnd.github.v3+json', 'Authorization': `Bearer ${token}`, 'User-Agent': 'DollhouseMCP/1.0' } }); if (response.status === 404) { // File doesn't exist, not a duplicate return false; } if (!response.ok) { logger.warn('Failed to check existing content', { status: response.status, path: filePath }); // On error, allow upload to proceed return false; } const data = await response.json(); // GitHub returns content as base64 const existingContent = Buffer.from(data.content, 'base64').toString('utf-8'); // Compare content hashes const existingHash = createHash('sha256').update(existingContent).digest('hex'); const newHash = createHash('sha256').update(content).digest('hex'); const isDuplicate = existingHash === newHash; if (isDuplicate) { logger.info('Duplicate content detected, skipping upload', { path: filePath, hash: newHash.substring(0, 8) // Log partial hash for debugging }); } return isDuplicate; } catch (error) { logger.warn('Error checking for existing content', { error: error instanceof Error ? error.message : String(error), path: filePath }); // On error, allow upload to proceed rather than blocking return false; } } /** * Check if an issue for this content already exists in the collection * @param elementName Name of the element to check * @param username User submitting the element * @param token GitHub token for API access * @returns URL of existing issue if found, null otherwise */ private async checkExistingIssue( elementName: string, username: string, token: string ): Promise<string | null> { try { // Search for existing issues with this element name // Using both title and author to ensure it's the same submission const query = `repo:DollhouseMCP/collection is:issue "${elementName}" in:title author:${username}`; const url = `https://api.github.com/search/issues?q=${encodeURIComponent(query)}&sort=created&order=desc`; const response = await fetch(url, { headers: { 'Accept': 'application/vnd.github.v3+json', 'Authorization': `Bearer ${token}`, 'User-Agent': 'DollhouseMCP/1.0' } }); if (!response.ok) { logger.warn('Failed to search for existing issues', { status: response.status }); return null; } const data = await response.json(); if (data.items && data.items.length > 0) { // Found existing issue(s) const existingIssue = data.items[0]; logger.info('Found existing collection issue', { issueUrl: existingIssue.html_url, elementName }); return existingIssue.html_url; } return null; } catch (error) { logger.warn('Error checking for existing collection issue', { error: error instanceof Error ? error.message : String(error), elementName }); // On error, allow submission to proceed return null; } } /** * Submits element to portfolio and handles the complete response workflow * @param safeName The normalized name of the element * @param elementType The type of the element * @param metadata The metadata for the element * @param content The content of the element * @param authStatus Authentication status containing username and token * @returns Complete submission result with success message or error */ private async submitElementAndHandleResponse( safeName: string, elementType: ElementType, metadata: PortfolioElementMetadata, content: string, authStatus: any, localPath?: string // Local file path for collection submission ): Promise<SubmitToPortfolioResult> { // DUPLICATE DETECTION: Check if content already exists in portfolio const repoFullName = `${authStatus.username}/${this.portfolioManager.getRepositoryName()}`; const filePath = `${elementType}/${safeName}.md`; // Prepare the full content that would be saved const fullContent = `---\n${JSON.stringify(metadata, null, 2)}\n---\n\n${content}`; const isDuplicate = await this.checkExistingContent( repoFullName, filePath, fullContent, authStatus.token ); let fileUrl: string | null = null; if (isDuplicate) { // Content already exists, construct the URL without uploading fileUrl = `https://github.com/${repoFullName}/blob/main/${filePath}`; logger.info('Skipped duplicate upload to portfolio', { elementName: safeName, path: filePath }); } else { // Create element structure to save const element: PortfolioElement = { type: elementType, metadata, content }; // TYPE SAFETY FIX #2: Use adapter pattern instead of complex type casting // Previously: element as unknown as Parameters<typeof this.portfolioManager.saveElement>[0] // Now: Clean adapter pattern that implements IElement interface properly const adapter = new PortfolioElementAdapter(element); // UX IMPROVEMENT: Add retry logic for transient failures fileUrl = await this.saveElementWithRetry(adapter, safeName, elementType); } if (!fileUrl) { return { success: false, message: 'Failed to save element to GitHub portfolio after multiple attempts.\n\n' + 'šŸ’” **Troubleshooting Tips**:\n' + '• Check your GitHub authentication: `gh auth status`\n' + '• Verify repository permissions\n' + '• Try again in a few minutes (GitHub API rate limits)\n' + '• Check GitHub status: https://status.github.com', error: 'SAVE_FAILED' }; } // Log submission result (DMCP-SEC-006) // Check if this was a duplicate skip or actual upload const wasDuplicate = fileUrl && fileUrl.includes('blob/main/') && !fileUrl.includes('/commit/'); if (wasDuplicate) { logger.info(`Skipped duplicate upload for ${safeName} - already in portfolio`, { elementType, username: authStatus.username, fileUrl }); } else { logger.info(`Successfully submitted ${safeName} to GitHub portfolio`, { elementType, username: authStatus.username, fileUrl }); } // SECURITY ENHANCEMENT (Task #14): Smart token management for collection submission const collectionTokenManagement = await this.manageTokenForLongOperation('collection_submission'); if (!collectionTokenManagement.canProceed) { // Token management failed for collection submission, but main submission succeeded const errorMessage = collectionTokenManagement.error?.message || 'Token management failed'; const portfolioMessage = wasDuplicate ? `āœ… ${safeName} already exists in your GitHub portfolio (no changes needed)` : `āœ… Successfully uploaded ${safeName} to your GitHub portfolio!`; return { success: true, message: `${portfolioMessage}\nšŸ“ Portfolio URL: ${fileUrl}\n\nāš ļø Collection submission skipped: ${errorMessage}`, url: fileUrl }; } const token = collectionTokenManagement.token!; // Provide refresh guidance if recommended for collection submission if (collectionTokenManagement.refreshRecommended) { const tokenType = TokenManager.getTokenType(token); logger.info('Collection submission proceeding with aging token', { tokenType, recommendation: 'If collection submission fails, try re-authenticating with setup_github_auth' }); } // ENHANCEMENT (Issue #549): Ask user if they want to submit to collection // This completes the community contribution workflow const collectionSubmissionResult = await this.promptForCollectionSubmission({ elementName: safeName, elementType, portfolioUrl: fileUrl, username: authStatus.username || 'unknown', metadata, token, localPath // Pass the local file path for reading content }); // Build the response message based on what happened const portfolioMessage = wasDuplicate ? `āœ… ${safeName} already exists in your GitHub portfolio (no changes needed)` : `āœ… Successfully uploaded ${safeName} to your GitHub portfolio!`; let message = `${portfolioMessage}\n`; message += `šŸ“ Portfolio URL: ${fileUrl}\n\n`; if (collectionSubmissionResult.submitted) { if (collectionSubmissionResult.isDuplicate) { message += `šŸ“‹ Collection issue already exists (no new submission needed)\n`; message += `šŸ”— Issue: ${collectionSubmissionResult.issueUrl}`; } else { message += `šŸŽ‰ Also submitted to DollhouseMCP collection for community review!\n`; message += `šŸ“‹ Issue: ${collectionSubmissionResult.issueUrl}`; } } else if (collectionSubmissionResult.declined) { message += `šŸ’” You can submit to the collection later using the same command.`; } else if (collectionSubmissionResult.error) { message += `āš ļø Collection submission failed: ${collectionSubmissionResult.error}\n`; message += `šŸ’” You can manually submit at: https://github.com/DollhouseMCP/collection/issues/new`; } return { success: true, message, url: fileUrl }; } async execute(params: SubmitToPortfolioParams): Promise<SubmitToPortfolioResult> { const startTime = Date.now(); const timings: Record<string, number> = {}; logger.info('šŸš€ SUBMISSION WORKFLOW STARTING', { params: JSON.stringify(params), timestamp: new Date().toISOString() }); try { // Step 1: Validate and normalize input parameters logger.info('šŸ“‹ Step 1/8: Validating parameters...'); const step1Start = Date.now(); const validationResult = await this.validateAndNormalizeParams(params); timings['validation'] = Date.now() - step1Start; logger.info(`āœ… Step 1 complete (${timings['validation']}ms)`); if (!validationResult.success) { return validationResult.error!; } const safeName = validationResult.safeName!; // Step 2: Check authentication status logger.info('šŸ” Step 2/8: Checking authentication...'); const step2Start = Date.now(); const authResult = await this.checkAuthentication(); timings['authentication'] = Date.now() - step2Start; logger.info(`āœ… Step 2 complete (${timings['authentication']}ms)`, { username: authResult.authStatus?.username, hasToken: !!authResult.authStatus?.hasToken }); if (!authResult.success) { return authResult.error!; } const authStatus = authResult.authStatus!; // Step 3: Find content locally with smart type detection logger.info('šŸ” Step 3/8: Finding content locally...'); const step3Start = Date.now(); const contentResult = await this.discoverContentWithTypeDetection(safeName!, params.type, params.name); timings['contentDiscovery'] = Date.now() - step3Start; logger.info(`āœ… Step 3 complete (${timings['contentDiscovery']}ms)`, { found: contentResult.success, elementType: contentResult.elementType, path: contentResult.localPath }); if (!contentResult.success) { return contentResult.error!; } const elementType = contentResult.elementType!; const localPath = contentResult.localPath!; // Step 4: Validate file and content security logger.info('šŸ”’ Step 4/8: Validating security...'); const step4Start = Date.now(); const securityResult = await this.validateFileAndContent(localPath); timings['security'] = Date.now() - step4Start; logger.info(`āœ… Step 4 complete (${timings['security']}ms)`); if (!securityResult.success) { return securityResult.error!; } const content = securityResult.content!; // Step 5: Prepare metadata for element logger.info('šŸ“ Step 5/8: Preparing metadata...'); const step5Start = Date.now(); const metadata = await this.prepareElementMetadata(safeName!, elementType, authStatus, localPath); timings['metadata'] = Date.now() - step5Start; logger.info(`āœ… Step 5 complete (${timings['metadata']}ms)`, { author: metadata.author, elementName: safeName }); // Step 6: Set up GitHub repository access logger.info('šŸ”§ Step 6/8: Setting up GitHub repository...'); const step6Start = Date.now(); const repoResult = await this.setupGitHubRepository(authStatus); timings['repoSetup'] = Date.now() - step6Start; logger.info(`āœ… Step 6 complete (${timings['repoSetup']}ms)`, { success: repoResult.success }); if (!repoResult.success) { return repoResult.error!; } // Step 7: Submit element to portfolio and handle collection submission logger.info('šŸ“¤ Step 7/8: Submitting to portfolio...'); const step7Start = Date.now(); const result = await this.submitElementAndHandleResponse( safeName!, elementType, metadata, content, authStatus, localPath // Pass file path for collection submission ); timings['submission'] = Date.now() - step7Start; // Step 8: Final reporting timings['total'] = Date.now() - startTime; logger.info('✨ SUBMISSION WORKFLOW COMPLETE', { success: result.success, timings, totalTime: `${timings['total']}ms` }); return result; } catch (error) { // SECURITY ENHANCEMENT (Task #14): Enhanced error handling with token refresh guidance ErrorHandler.logError('submitToPortfolio', error, { elementName: params.name, elementType: params.type }); // Check if error is token-related and provide refresh guidance const errorMessage = error instanceof Error ? error.message : String(error); const isTokenError = errorMessage.toLowerCase().includes('token') || errorMessage.toLowerCase().includes('auth') || errorMessage.toLowerCase().includes('401') || errorMessage.toLowerCase().includes('403'); let formattedError = ErrorHandler.formatForResponse(error); if (isTokenError) { try { // Get current token to determine type for guidance const currentToken = await TokenManager.getGitHubTokenAsync(); if (currentToken) { const tokenType = TokenManager.getTokenType(currentToken); const refreshGuidance = this.formatTokenRefreshGuidance('portfolio submission', tokenType); // Append refresh guidance to error message if (formattedError.message) { formattedError.message += refreshGuidance; } }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/DollhouseMCP/mcp-server'

If you have feedback or need assistance with the MCP directory API, please join our Discord server