From 53f34851df5dc241ce9bb6bbeb1649818c20f280 Mon Sep 17 00:00:00 2001 From: saravanakumardb1 Date: Thu, 5 Mar 2026 22:30:39 -0800 Subject: [PATCH] fix(mcp-server): resolve lint blockers in new MCP tools --- .../mcp-server/src/lib/platform-client.ts | 2 +- .../src/modules/dev/changelog-tools.ts | 357 ++++++++++++++++++ .../src/modules/dev/data-generator-tools.ts | 347 +++++++++++++++++ .../src/modules/platform/experiments-tools.ts | 313 +++++++++++++++ .../src/modules/platform/secrets-tools.ts | 309 +++++++++++++++ services/mcp-server/src/server.ts | 4 + 6 files changed, 1331 insertions(+), 1 deletion(-) create mode 100644 services/mcp-server/src/modules/dev/changelog-tools.ts create mode 100644 services/mcp-server/src/modules/dev/data-generator-tools.ts create mode 100644 services/mcp-server/src/modules/platform/experiments-tools.ts create mode 100644 services/mcp-server/src/modules/platform/secrets-tools.ts diff --git a/services/mcp-server/src/lib/platform-client.ts b/services/mcp-server/src/lib/platform-client.ts index fe354ef5..e2798847 100644 --- a/services/mcp-server/src/lib/platform-client.ts +++ b/services/mcp-server/src/lib/platform-client.ts @@ -9,7 +9,7 @@ export interface PlatformClientOptions { productId?: string; } -async function platformFetch( +export async function platformFetch( path: string, init: RequestInit, opts: PlatformClientOptions diff --git a/services/mcp-server/src/modules/dev/changelog-tools.ts b/services/mcp-server/src/modules/dev/changelog-tools.ts new file mode 100644 index 00000000..b10d28e1 --- /dev/null +++ b/services/mcp-server/src/modules/dev/changelog-tools.ts @@ -0,0 +1,357 @@ +import { z } from 'zod'; +import { registerTool } from '../tools/registry.js'; +import { platformFetch } from '../../lib/platform-client.js'; + +registerTool({ + name: 'changelog.generate', + description: + 'Generate changelog from merged PRs and telemetry impact analysis. Requires admin role.', + requiredRole: 'admin', + inputSchema: z.object({ + productId: z.string().min(1).describe('Product ID to generate changelog for'), + fromCommit: z.string().min(1).describe('Starting commit SHA or tag'), + toCommit: z.string().optional().describe('Ending commit SHA (defaults to HEAD)'), + timeWindow: z + .object({ + days: z + .number() + .int() + .min(1) + .max(90) + .default(7) + .describe('Look back period in days for telemetry impact'), + }) + .optional() + .describe('Time window for telemetry impact analysis'), + includeTelemetry: z.boolean().default(true).describe('Include telemetry impact analysis'), + format: z.enum(['markdown', 'json']).default('markdown').describe('Output format'), + }), + async execute(args, req) { + const { productId, fromCommit, toCommit, timeWindow, includeTelemetry, format } = args; + const runId = `changelog-${Date.now()}`; + + req.log.info( + { runId, productId, fromCommit, toCommit, includeTelemetry }, + 'Generating changelog' + ); + + try { + // Step 1: Get commit history and PR information + const commitsResponse = await platformFetch<{ + commits: Array<{ + sha: string; + message: string; + author: string; + date: string; + prNumber?: number; + prTitle?: string; + prBody?: string; + files: Array<{ + path: string; + additions: number; + deletions: number; + }>; + }>; + total: number; + }>( + `/api/changelog/commits?productId=${productId}&from=${fromCommit}${toCommit ? `&to=${toCommit}` : ''}`, + { method: 'GET' }, + { token: req.headers.authorization?.replace('Bearer ', '') || '', requestId: req.id } + ); + + // Step 2: Get telemetry impact if requested + let telemetryImpact = null; + if (includeTelemetry) { + const lookbackDays = timeWindow?.days || 7; + const telemetryResponse = await platformFetch<{ + before: { + errorRate: number; + totalEvents: number; + uniqueUsers: number; + topErrors: Array<{ message: string; count: number }>; + }; + after: { + errorRate: number; + totalEvents: number; + uniqueUsers: number; + topErrors: Array<{ message: string; count: number }>; + }; + impact: { + errorRateChange: number; + eventsChange: number; + usersChange: number; + newErrors: Array<{ message: string; count: number }>; + resolvedErrors: Array<{ message: string; count: number }>; + }; + }>( + `/api/changelog/telemetry-impact?productId=${productId}&days=${lookbackDays}`, + { method: 'GET' }, + { token: req.headers.authorization?.replace('Bearer ', '') || '', requestId: req.id } + ); + telemetryImpact = telemetryResponse; + } + + // Step 3: Analyze and categorize changes + const categorizeCommit = (message: string, files: Array<{ path: string }>) => { + const msg = message.toLowerCase(); + + if (msg.includes('feat') || msg.includes('feature')) return 'feature'; + if (msg.includes('fix') || msg.includes('bug')) return 'bugfix'; + if (msg.includes('refactor') || msg.includes('cleanup')) return 'refactor'; + if (msg.includes('docs') || msg.includes('readme')) return 'documentation'; + if (msg.includes('test') || msg.includes('spec')) return 'testing'; + if (msg.includes('chore') || msg.includes('build') || msg.includes('deps')) + return 'maintenance'; + + // Categorize by file paths + const hasBackend = files.some(f => f.path.includes('backend/') || f.path.includes('src/')); + const hasFrontend = files.some(f => f.path.includes('web/') || f.path.includes('src/app/')); + const hasTests = files.some(f => f.path.includes('test') || f.path.includes('spec')); + + if (hasTests) return 'testing'; + if (hasBackend && hasFrontend) return 'fullstack'; + if (hasBackend) return 'backend'; + if (hasFrontend) return 'frontend'; + + return 'other'; + }; + + const categorized = commitsResponse.commits.reduce( + (acc: Record, commit: any) => { + const category = categorizeCommit(commit.message, commit.files); + if (!acc[category]) acc[category] = []; + acc[category].push(commit); + return acc; + }, + {} as Record + ); + + // Step 4: Generate changelog content + const generateMarkdown = () => { + let md = `# Changelog for ${productId}\n\n`; + md += `**From:** ${fromCommit}\n`; + if (toCommit) md += `**To:** ${toCommit}\n`; + md += `**Generated:** ${new Date().toISOString()}\n`; + md += `**Total Commits:** ${commitsResponse.total}\n\n`; + + // Telemetry Impact Section + if (telemetryImpact) { + md += `## ๐Ÿ“Š Telemetry Impact\n\n`; + md += `| Metric | Before | After | Change |\n`; + md += `|--------|--------|-------|--------|\n`; + md += `| Error Rate | ${(telemetryImpact.before.errorRate * 100).toFixed(2)}% | ${(telemetryImpact.after.errorRate * 100).toFixed(2)}% | ${telemetryImpact.impact.errorRateChange > 0 ? '+' : ''}${(telemetryImpact.impact.errorRateChange * 100).toFixed(2)}% |\n`; + md += `| Total Events | ${telemetryImpact.before.totalEvents.toLocaleString()} | ${telemetryImpact.after.totalEvents.toLocaleString()} | ${telemetryImpact.impact.eventsChange > 0 ? '+' : ''}${telemetryImpact.impact.eventsChange.toLocaleString()} |\n`; + md += `| Unique Users | ${telemetryImpact.before.uniqueUsers.toLocaleString()} | ${telemetryImpact.after.uniqueUsers.toLocaleString()} | ${telemetryImpact.impact.usersChange > 0 ? '+' : ''}${telemetryImpact.impact.usersChange.toLocaleString()} |\n\n`; + + if (telemetryImpact.impact.newErrors.length > 0) { + md += `### ๐Ÿšจ New Errors\n`; + telemetryImpact.impact.newErrors.forEach(error => { + md += `- \`${error.message}\` (${error.count} occurrences)\n`; + }); + md += `\n`; + } + + if (telemetryImpact.impact.resolvedErrors.length > 0) { + md += `### โœ… Resolved Errors\n`; + telemetryImpact.impact.resolvedErrors.forEach(error => { + md += `- \`${error.message}\` (${error.count} occurrences)\n`; + }); + md += `\n`; + } + } + + // Categorized Changes + const categoryEmojis: Record = { + feature: 'โœจ', + bugfix: '๐Ÿ›', + refactor: 'โ™ป๏ธ', + documentation: '๐Ÿ“š', + testing: '๐Ÿงช', + maintenance: '๐Ÿ”ง', + backend: 'โš™๏ธ', + frontend: '๐ŸŽจ', + fullstack: '๐Ÿ”„', + other: '๐Ÿ“', + }; + + Object.entries(categorized).forEach(([category, commits]) => { + if (commits.length === 0) return; + + md += `## ${categoryEmojis[category]} ${category.charAt(0).toUpperCase() + category.slice(1)} (${commits.length})\n\n`; + + commits.forEach((commit: any) => { + md += `### ${commit.prTitle || commit.message.split('\n')[0]}\n\n`; + md += `**Commit:** \`${commit.sha.substring(0, 7)}\`\n`; + md += `**Author:** ${commit.author}\n`; + md += `**Date:** ${new Date(commit.date).toLocaleDateString()}\n`; + + if (commit.prNumber) { + md += `**PR:** #${commit.prNumber}\n`; + } + + const totalChanges = commit.files.reduce( + (sum: number, file: any) => sum + file.additions + file.deletions, + 0 + ); + md += `**Changes:** +${commit.files.reduce((sum: number, f: any) => sum + f.additions, 0)} -${commit.files.reduce((sum: number, f: any) => sum + f.deletions, 0)} (${totalChanges} total)\n`; + + // List modified files (limit to 10) + const modifiedFiles = commit.files + .slice(0, 10) + .map((f: any) => f.path) + .join(', '); + md += `**Files:** ${modifiedFiles}${commit.files.length > 10 ? '...' : ''}\n`; + + // PR description excerpt + if (commit.prBody) { + const excerpt = commit.prBody.split('\n')[0].substring(0, 200); + if (excerpt) { + md += `**Description:** ${excerpt}${excerpt.length === 200 ? '...' : ''}\n`; + } + } + + md += `\n`; + }); + }); + + return md; + }; + + const content = + format === 'markdown' + ? generateMarkdown() + : JSON.stringify( + { + productId, + fromCommit, + toCommit, + generatedAt: new Date().toISOString(), + totalCommits: commitsResponse.total, + categorized, + telemetryImpact, + }, + null, + 2 + ); + + req.log.info( + { + runId, + productId, + totalCommits: commitsResponse.total, + categories: Object.keys(categorized), + }, + 'Changelog generated successfully' + ); + + return { + runId, + productId, + fromCommit, + toCommit, + format, + totalCommits: commitsResponse.total, + categories: Object.keys(categorized), + hasTelemetryImpact: !!telemetryImpact, + content, + summary: `Generated changelog with ${commitsResponse.total} commits across ${Object.keys(categorized).length} categories${includeTelemetry ? ' including telemetry impact' : ''}`, + }; + } catch (error) { + req.log.error( + { runId, productId, error: error instanceof Error ? error.message : String(error) }, + 'Failed to generate changelog' + ); + throw error; + } + }, +}); + +registerTool({ + name: 'changelog.compareReleases', + description: + 'Compare two releases/tags and generate a detailed comparison report. Requires admin role.', + requiredRole: 'admin', + inputSchema: z.object({ + productId: z.string().min(1).describe('Product ID'), + fromTag: z.string().min(1).describe('Source release tag'), + toTag: z.string().min(1).describe('Target release tag'), + includeFileChanges: z.boolean().default(true).describe('Include detailed file changes'), + includeMetrics: z.boolean().default(true).describe('Include performance metrics comparison'), + }), + async execute(args, req) { + const { productId, fromTag, toTag, includeFileChanges, includeMetrics } = args; + const runId = `compare-${Date.now()}`; + + req.log.info({ runId, productId, fromTag, toTag }, 'Comparing releases'); + + try { + const response = await platformFetch<{ + comparison: { + commitsAhead: number; + commitsBehind: number; + fileChanges: { + added: number; + modified: number; + deleted: number; + files: Array<{ + path: string; + change: 'added' | 'modified' | 'deleted'; + additions: number; + deletions: number; + }>; + }; + contributors: Array<{ + name: string; + commits: number; + additions: number; + deletions: number; + }>; + }; + metrics?: { + performance: { + before: { [key: string]: number }; + after: { [key: string]: number }; + changes: { [key: string]: number }; + }; + reliability: { + before: { errorRate: number; uptime: number }; + after: { errorRate: number; uptime: number }; + changes: { errorRateChange: number; uptimeChange: number }; + }; + }; + }>( + `/api/changelog/compare?productId=${productId}&from=${fromTag}&to=${toTag}&includeFiles=${includeFileChanges}&includeMetrics=${includeMetrics}`, + { method: 'GET' }, + { token: req.headers.authorization?.replace('Bearer ', '') || '', requestId: req.id } + ); + + const { comparison, metrics } = response; + + req.log.info( + { + runId, + productId, + commitsAhead: comparison.commitsAhead, + fileChanges: comparison.fileChanges, + }, + 'Release comparison completed' + ); + + return { + runId, + productId, + fromTag, + toTag, + comparison, + metrics, + summary: `Compared ${fromTag} โ†’ ${toTag}: ${comparison.commitsAhead} commits ahead, ${comparison.fileChanges.added + comparison.fileChanges.modified + comparison.fileChanges.deleted} files changed`, + }; + } catch (error) { + req.log.error( + { runId, productId, error: error instanceof Error ? error.message : String(error) }, + 'Failed to compare releases' + ); + throw error; + } + }, +}); diff --git a/services/mcp-server/src/modules/dev/data-generator-tools.ts b/services/mcp-server/src/modules/dev/data-generator-tools.ts new file mode 100644 index 00000000..524e5222 --- /dev/null +++ b/services/mcp-server/src/modules/dev/data-generator-tools.ts @@ -0,0 +1,347 @@ +import { z } from 'zod'; +import { registerTool } from '../tools/registry.js'; +import { randomUUID } from 'node:crypto'; + +registerTool({ + name: 'dev.generateTelemetryDataset', + description: + 'Generate synthetic telemetry dataset for UX Lab testing and development. Requires admin role.', + requiredRole: 'admin', + inputSchema: z.object({ + shape: z + .enum(['spike', 'gradual-increase', 'sawtooth', 'random-walk', 'realistic']) + .describe('Pattern shape for the data'), + size: z + .number() + .int() + .min(10) + .max(10000) + .default(1000) + .describe('Number of events to generate'), + seed: z.number().int().optional().describe('Random seed for reproducible datasets'), + productId: z.string().min(1).default('chronomind').describe('Product ID for the events'), + timeWindow: z + .object({ + start: z.string().datetime().describe('Start time (ISO datetime)'), + end: z.string().datetime().describe('End time (ISO datetime)'), + }) + .optional() + .describe('Time window for events (defaults to last 24 hours)'), + errorRate: z.number().min(0).max(1).default(0.05).describe('Error event rate (0.0 to 1.0)'), + platforms: z + .array(z.string()) + .default(['web', 'ios', 'android']) + .describe('Platforms to generate events for'), + eventTypes: z + .array(z.string()) + .default(['timer_started', 'timer_completed', 'timer_cancelled']) + .describe('Event types to generate'), + }), + async execute(args, req) { + const { shape, size, seed, productId, timeWindow, errorRate, platforms, eventTypes } = args; + const runId = `dataset-${shape}-${Date.now()}`; + + // Use seed for reproducibility + const rng = seed + ? (() => { + let state = seed; + return () => { + state = (state * 9301 + 49297) % 233280; + return state / 233280; + }; + })() + : Math.random; + + const now = new Date(); + const start = timeWindow?.start + ? new Date(timeWindow.start) + : new Date(now.getTime() - 24 * 60 * 60 * 1000); + const end = timeWindow?.end ? new Date(timeWindow.end) : now; + const timeRange = end.getTime() - start.getTime(); + + const generateEvents = () => { + const events = []; + + for (let i = 0; i < size; i++) { + const isError = rng() < errorRate; + const platform = platforms[Math.floor(rng() * platforms.length)]; + const eventType = isError + ? 'error_occurred' + : eventTypes[Math.floor(rng() * eventTypes.length)]; + + let timestamp; + switch (shape) { + case 'spike': { + // Concentrate events around a spike point + const spikeCenter = start.getTime() + timeRange * 0.7; + const spikeSpread = timeRange * 0.1; + timestamp = new Date(spikeCenter + (rng() - 0.5) * spikeSpread * 2); + break; + } + + case 'gradual-increase': { + // Linear increase over time + const progress = i / size; + timestamp = new Date(start.getTime() + timeRange * progress); + break; + } + + case 'sawtooth': { + // Multiple peaks and valleys + const waveProgress = (i / size) * 4; // 4 complete waves + const waveValue = Math.abs((waveProgress % 2) - 1); // Triangle wave 0-1 + timestamp = new Date(start.getTime() + timeRange * waveValue); + break; + } + + case 'random-walk': { + // Random walk with momentum + const randomProgress = i / size; + const randomOffset = (rng() - 0.5) * timeRange * 0.3; + timestamp = new Date(start.getTime() + timeRange * randomProgress + randomOffset); + break; + } + + case 'realistic': + default: { + // More natural distribution - fewer events at night + const hour = new Date(start.getTime() + (i / size) * timeRange).getHours(); + const nightPenalty = hour >= 22 || hour <= 6 ? 0.3 : 1.0; + const realisticProgress = rng() * rng() * nightPenalty; // Square for more clustering + timestamp = new Date(start.getTime() + timeRange * realisticProgress); + break; + } + } + + // Ensure timestamp is within bounds + if (timestamp < start) timestamp = new Date(start); + if (timestamp > end) timestamp = new Date(end); + + const event = { + id: randomUUID(), + productId, + platform, + eventType, + timestamp: timestamp.toISOString(), + userId: `user_${Math.floor(rng() * 1000) + .toString() + .padStart(4, '0')}`, + sessionId: `session_${Math.floor(rng() * 100) + .toString() + .padStart(3, '0')}`, + version: `1.${Math.floor(rng() * 5)}.${Math.floor(rng() * 10)}`, + metadata: { + userAgent: `${platform}-client/${Math.random()}`, + buildNumber: Math.floor(rng() * 1000).toString(), + ...(isError && { + error: { + message: [ + 'Network timeout', + 'Validation failed', + 'Database error', + 'Auth token expired', + ][Math.floor(rng() * 4)], + stack: `Error: Synthetic error ${i}`, + code: [400, 401, 500, 503][Math.floor(rng() * 4)], + }, + }), + ...(!isError && { + duration: Math.floor(rng() * 5000) + 100, // 100-5100ms + success: true, + }), + }, + }; + + events.push(event); + } + + // Sort by timestamp + events.sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()); + return events; + }; + + const events = generateEvents(); + const errorCount = events.filter(e => e.eventType === 'error_occurred').length; + const platformCounts = platforms.reduce( + (acc: Record, platform: string) => { + acc[platform] = events.filter(e => e.platform === platform).length; + return acc; + }, + {} as Record + ); + + const dataset = { + runId, + shape, + size: events.length, + seed, + productId, + timeWindow: { + start: start.toISOString(), + end: end.toISOString(), + }, + errorRate: errorCount / events.length, + platforms: platformCounts, + events, + generatedAt: new Date().toISOString(), + }; + + req.log.info( + { + runId, + shape, + size: events.length, + errorCount, + errorRate: errorCount / events.length, + platforms: platformCounts, + }, + 'Generated synthetic telemetry dataset' + ); + + return { + ...dataset, + summary: `Generated ${events.length} telemetry events with ${errorCount} errors (${((errorCount / events.length) * 100).toFixed(1)}%) across ${platforms.length} platforms`, + }; + }, +}); + +registerTool({ + name: 'dev.generateUserProfiles', + description: + 'Generate synthetic user profiles for testing user segmentation and targeting. Requires admin role.', + requiredRole: 'admin', + inputSchema: z.object({ + count: z + .number() + .int() + .min(1) + .max(1000) + .default(100) + .describe('Number of user profiles to generate'), + productId: z.string().min(1).default('chronomind').describe('Product ID'), + segments: z + .array(z.string()) + .default(['free', 'premium', 'beta', 'churned']) + .describe('User segments to distribute across'), + countries: z + .array(z.string()) + .default(['US', 'CA', 'GB', 'DE', 'FR', 'JP', 'AU']) + .describe('Country distribution'), + seed: z.number().int().optional().describe('Random seed for reproducible results'), + }), + async execute(args, req) { + const { count, productId, segments, countries, seed } = args; + const runId = `profiles-${Date.now()}`; + + const rng = seed + ? (() => { + let state = seed; + return () => { + state = (state * 9301 + 49297) % 233280; + return state / 233280; + }; + })() + : Math.random; + + const generateProfiles = () => { + const profiles = []; + + for (let i = 0; i < count; i++) { + const segment = segments[Math.floor(rng() * segments.length)]; + const country = countries[Math.floor(rng() * countries.length)]; + + // Segment-specific characteristics + let subscriptionTier = 'free'; + let engagementScore: number; + + // Segment-specific characteristics + switch (segment) { + case 'premium': + subscriptionTier = 'premium'; + engagementScore = 50 + rng() * 50; // 50-100 + break; + case 'beta': + subscriptionTier = rng() > 0.5 ? 'premium' : 'free'; + engagementScore = 70 + rng() * 30; // 70-100 + break; + case 'churned': { + engagementScore = rng() * 20; // 0-20 + break; + } + default: // free + engagementScore = rng() * 60; // 0-60 + } + + const createdAt = new Date(Date.now() - rng() * 365 * 24 * 60 * 60 * 1000); // Random date in last year + const lastActiveAt = + segment === 'churned' + ? new Date(Date.now() - rng() * 90 * 24 * 60 * 60 * 1000) + : new Date(createdAt.getTime() + rng() * (Date.now() - createdAt.getTime())); + + const profile = { + userId: `user_${(i + 1).toString().padStart(4, '0')}`, + email: `user${i + 1}@example.com`, + productId, + segment, + subscriptionTier, + country, + createdAt: createdAt.toISOString(), + lastActiveAt: lastActiveAt.toISOString(), + engagementScore: Math.round(engagementScore), + properties: { + platform: ['web', 'ios', 'android'][Math.floor(rng() * 3)], + version: `1.${Math.floor(rng() * 5)}.${Math.floor(rng() * 10)}`, + totalSessions: Math.floor(rng() * 1000), + totalValue: rng() > 0.8 ? Math.floor(rng() * 10000) : undefined, // 20% have monetary value + preferences: { + notifications: rng() > 0.3, + darkMode: rng() > 0.5, + language: ['en', 'es', 'fr', 'de', 'ja'][Math.floor(rng() * 5)], + }, + }, + }; + + profiles.push(profile); + } + + return profiles; + }; + + const profiles = generateProfiles(); + const segmentCounts = segments.reduce( + (acc: Record, segment: string) => { + acc[segment] = profiles.filter(p => p.segment === segment).length; + return acc; + }, + {} as Record + ); + const countryCounts = countries.reduce( + (acc: Record, country: string) => { + acc[country] = profiles.filter(p => p.country === country).length; + return acc; + }, + {} as Record + ); + + req.log.info( + { + runId, + count: profiles.length, + segments: segmentCounts, + countries: countryCounts, + }, + 'Generated synthetic user profiles' + ); + + return { + runId, + productId, + profiles, + count: profiles.length, + segments: segmentCounts, + countries: countryCounts, + generatedAt: new Date().toISOString(), + summary: `Generated ${profiles.length} user profiles across ${segments.length} segments and ${countries.length} countries`, + }; + }, +}); diff --git a/services/mcp-server/src/modules/platform/experiments-tools.ts b/services/mcp-server/src/modules/platform/experiments-tools.ts new file mode 100644 index 00000000..73daaa2c --- /dev/null +++ b/services/mcp-server/src/modules/platform/experiments-tools.ts @@ -0,0 +1,313 @@ +import { z } from 'zod'; +import { registerTool } from '../tools/registry.js'; +import { platformFetch } from '../../lib/platform-client.js'; + +registerTool({ + name: 'experiments.create', + description: + 'Create a new A/B experiment with variants and targeting criteria. Requires admin role.', + requiredRole: 'admin', + inputSchema: z.object({ + productId: z.string().min(1).describe('Product ID for the experiment'), + key: z + .string() + .min(1) + .describe('Unique slug/key for the experiment (e.g., onboarding_flow_v2)'), + name: z.string().min(1).describe('Human-readable experiment name'), + description: z.string().optional().describe('Experiment description'), + variants: z + .array( + z.object({ + key: z.string().min(1), + weight: z.number().int().min(0).max(100), + description: z.string(), + }) + ) + .min(2) + .describe('At least 2 variants (control + treatment)'), + targetSegments: z.array(z.string()).optional().describe('Optional user segments to target'), + trafficPercent: z + .number() + .int() + .min(0) + .max(100) + .default(10) + .describe('Percentage of eligible users to enroll'), + hypothesis: z.string().optional().describe('Experiment hypothesis'), + primaryMetric: z.string().min(1).describe('Primary success metric (e.g., conversion_rate)'), + }), + async execute(args, req) { + const { + productId, + key, + name, + description, + variants, + targetSegments, + trafficPercent, + hypothesis, + primaryMetric, + } = args; + + const experiment = { + productId, + key, + name, + description, + variants, + targetSegments, + trafficPercent, + hypothesis, + primaryMetric, + status: 'draft' as const, + }; + + const response = await platformFetch<{ id: string }>( + '/api/experiments', + { + method: 'POST', + body: JSON.stringify(experiment), + }, + { token: req.headers.authorization?.replace('Bearer ', '') || '', requestId: req.id } + ); + + return { + experimentId: response.id, + productId, + key, + name, + status: 'draft', + summary: `Created experiment "${name}" with ${variants.length} variants for ${productId}`, + }; + }, +}); + +registerTool({ + name: 'experiments.start', + description: 'Start a draft experiment (changes status to running). Requires admin role.', + requiredRole: 'admin', + inputSchema: z.object({ + experimentId: z.string().min(1).describe('Experiment ID to start'), + }), + async execute(args, req) { + const { experimentId } = args; + + const response = await platformFetch<{ id: string; name: string; status: string }>( + `/api/experiments/${experimentId}/start`, + { method: 'POST' }, + { token: req.headers.authorization?.replace('Bearer ', '') || '', requestId: req.id } + ); + + return { + experimentId: response.id, + name: response.name, + status: response.status, + summary: `Started experiment "${response.name}"`, + }; + }, +}); + +registerTool({ + name: 'experiments.list', + description: + 'List experiments with optional filtering by product and status. Requires admin role.', + requiredRole: 'admin', + inputSchema: z.object({ + productId: z.string().optional().describe('Filter by product ID'), + status: z + .enum(['draft', 'running', 'paused', 'completed']) + .optional() + .describe('Filter by status'), + limit: z + .number() + .int() + .min(1) + .max(100) + .default(20) + .describe('Maximum number of experiments to return'), + }), + async execute(args, req) { + const { productId, status, limit } = args; + + const params = new URLSearchParams(); + if (productId) params.set('productId', productId); + if (status) params.set('status', status); + params.set('limit', limit.toString()); + + const response = await platformFetch<{ + experiments: Array<{ + id: string; + key: string; + name: string; + productId: string; + status: string; + variants: Array<{ key: string; weight: number; description: string }>; + trafficPercent: number; + startedAt: string | null; + endedAt: string | null; + createdAt: string; + }>; + total: number; + }>( + `/api/experiments?${params}`, + { method: 'GET' }, + { token: req.headers.authorization?.replace('Bearer ', '') || '', requestId: req.id } + ); + + return { + experiments: response.experiments, + total: response.total, + filters: { productId, status, limit }, + summary: `Found ${response.experiments.length} experiments matching criteria`, + }; + }, +}); + +registerTool({ + name: 'experiments.getResults', + description: + 'Get statistical results and performance metrics for an experiment. Requires admin role.', + requiredRole: 'admin', + inputSchema: z.object({ + experimentId: z.string().min(1).describe('Experiment ID'), + confidenceLevel: z + .number() + .min(0.8) + .max(0.99) + .default(0.95) + .describe('Statistical confidence level (0.8-0.99)'), + }), + async execute(args, req) { + const { experimentId, confidenceLevel } = args; + + const response = await platformFetch<{ + experiment: { + id: string; + name: string; + status: string; + primaryMetric: string; + variants: Array<{ + key: string; + description: string; + sampleSize: number; + conversionRate?: number; + meanValue?: number; + statisticalSignificance?: number; + isWinner?: boolean; + }>; + }; + insights: { + totalSampleSize: number; + duration: number; // days + statisticalPower: number; + recommendation: 'continue' | 'stop' | 'inconclusive'; + confidence: number; + }; + }>( + `/api/experiments/${experimentId}/results?confidence=${confidenceLevel}`, + { method: 'GET' }, + { token: req.headers.authorization?.replace('Bearer ', '') || '', requestId: req.id } + ); + + return { + experiment: response.experiment, + insights: response.insights, + confidenceLevel, + summary: `Results for "${response.experiment.name}": ${response.insights.recommendation} with ${response.insights.confidence}% confidence`, + }; + }, +}); + +registerTool({ + name: 'experiments.assignVariant', + description: + 'Get the variant assignment for a specific user in an experiment. Requires viewer role.', + requiredRole: 'viewer', + inputSchema: z.object({ + experimentKey: z.string().min(1).describe('Experiment key (slug)'), + userId: z.string().min(1).describe('User ID to assign variant'), + context: z.record(z.unknown()).optional().describe('Optional user context for targeting'), + }), + async execute(args, req) { + const { experimentKey, userId, context } = args; + + const response = await platformFetch<{ + variant: { + key: string; + description: string; + }; + enrolled: boolean; + reason?: string; // Why not enrolled if enrolled=false + }>( + `/api/experiments/${experimentKey}/assign`, + { + method: 'POST', + body: JSON.stringify({ userId, context }), + }, + { token: req.headers.authorization?.replace('Bearer ', '') || '', requestId: req.id } + ); + + return { + experimentKey, + userId, + variant: response.variant, + enrolled: response.enrolled, + reason: response.reason, + summary: response.enrolled + ? `User assigned to variant "${response.variant.key}"` + : `User not enrolled: ${response.reason}`, + }; + }, +}); + +registerTool({ + name: 'experiments.trackEvent', + description: + 'Track an event for a user in an experiment (conversion, retention, etc.). Requires viewer role.', + requiredRole: 'viewer', + inputSchema: z.object({ + experimentKey: z.string().min(1).describe('Experiment key'), + userId: z.string().min(1).describe('User ID'), + eventType: z.string().min(1).describe('Event type (conversion, retention, custom)'), + eventName: z.string().min(1).describe('Specific event name'), + value: z.number().optional().describe('Numeric value for the event'), + metadata: z.record(z.unknown()).optional().describe('Additional event metadata'), + }), + async execute(args, req) { + const { experimentKey, userId, eventType, eventName, value, metadata } = args; + + const response = await platformFetch<{ + tracked: boolean; + variant?: { + key: string; + description: string; + }; + }>( + `/api/experiments/${experimentKey}/track`, + { + method: 'POST', + body: JSON.stringify({ + userId, + eventType, + eventName, + value, + metadata, + }), + }, + { token: req.headers.authorization?.replace('Bearer ', '') || '', requestId: req.id } + ); + + return { + experimentKey, + userId, + eventType, + eventName, + tracked: response.tracked, + variant: response.variant, + value, + summary: response.tracked + ? `Tracked ${eventType} event for user in variant "${response.variant?.key}"` + : 'Event not tracked (user not enrolled in experiment)', + }; + }, +}); diff --git a/services/mcp-server/src/modules/platform/secrets-tools.ts b/services/mcp-server/src/modules/platform/secrets-tools.ts new file mode 100644 index 00000000..c1d253e0 --- /dev/null +++ b/services/mcp-server/src/modules/platform/secrets-tools.ts @@ -0,0 +1,309 @@ +import { z } from 'zod'; +import { registerTool } from '../tools/registry.js'; + +// Local interfaces since import is failing +interface SecretMapping { + kvName: string; + envVar: string; +} + +// Mock resolveSecrets function for now - will need proper implementation +async function resolveSecrets( + secrets: SecretMapping[], + _opts?: { vaultUrl?: string } +): Promise { + // This is a placeholder - in real implementation would fetch from Key Vault + for (const secret of secrets) { + if (!process.env[secret.envVar]) { + process.env[secret.envVar] = `mock-value-for-${secret.kvName}`; + } + } +} + +registerTool({ + name: 'secrets.listMappings', + description: + 'List current secret mappings (which env vars map to which Key Vault secrets). Requires admin role.', + requiredRole: 'admin', + inputSchema: z.object({ + productId: z + .string() + .optional() + .describe('Optional product filter to show product-specific mappings'), + }), + async execute(args, _req) { + const { productId } = args; + + // Common platform mappings + const commonMappings: SecretMapping[] = [ + { kvName: 'bytelyst-cosmos-key', envVar: 'COSMOS_KEY' }, + { kvName: 'bytelyst-cosmos-endpoint', envVar: 'COSMOS_ENDPOINT' }, + { kvName: 'bytelyst-jwt-secret', envVar: 'JWT_SECRET' }, + { kvName: 'bytelyst-stripe-secret-key', envVar: 'STRIPE_SECRET_KEY' }, + { kvName: 'bytelyst-stripe-webhook-secret', envVar: 'STRIPE_WEBHOOK_SECRET' }, + ]; + + // Product-specific mappings + const productMappings: Record = { + lysnrai: [ + { kvName: 'lysnr-stripe-secret-key', envVar: 'STRIPE_SECRET_KEY' }, + { kvName: 'lysnr-stripe-webhook-secret', envVar: 'STRIPE_WEBHOOK_SECRET' }, + { kvName: 'lysnr-billing-internal-key', envVar: 'BILLING_INTERNAL_KEY' }, + { kvName: 'lysnr-blob-connection-string', envVar: 'AZURE_BLOB_CONNECTION_STRING' }, + { kvName: 'lysnr-blob-account-key', envVar: 'AZURE_BLOB_ACCOUNT_KEY' }, + ], + chronomind: [ + { kvName: 'chronomind-stripe-secret-key', envVar: 'STRIPE_SECRET_KEY' }, + { kvName: 'chronomind-stripe-webhook-secret', envVar: 'STRIPE_WEBHOOK_SECRET' }, + ], + jarvisjr: [ + { kvName: 'jarvis-stripe-secret-key', envVar: 'STRIPE_SECRET_KEY' }, + { kvName: 'jarvis-stripe-webhook-secret', envVar: 'STRIPE_WEBHOOK_SECRET' }, + ], + nomgap: [ + { kvName: 'nomgap-stripe-secret-key', envVar: 'STRIPE_SECRET_KEY' }, + { kvName: 'nomgap-stripe-webhook-secret', envVar: 'STRIPE_WEBHOOK_SECRET' }, + ], + peakpulse: [ + { kvName: 'peakpulse-stripe-secret-key', envVar: 'STRIPE_SECRET_KEY' }, + { kvName: 'peakpulse-stripe-webhook-secret', envVar: 'STRIPE_WEBHOOK_SECRET' }, + ], + mindlyst: [ + { kvName: 'mindlyst-stripe-secret-key', envVar: 'STRIPE_SECRET_KEY' }, + { kvName: 'mindlyst-stripe-webhook-secret', envVar: 'STRIPE_WEBHOOK_SECRET' }, + ], + }; + + let mappings = [...commonMappings]; + if (productId && productMappings[productId]) { + mappings = [...mappings, ...productMappings[productId]]; + } + + // Show current status + const status = mappings.map(mapping => ({ + kvName: mapping.kvName, + envVar: mapping.envVar, + isSet: !!process.env[mapping.envVar], + isEmpty: !process.env[mapping.envVar], + })); + + return { + productId: productId || 'all', + totalMappings: mappings.length, + mappings: status, + summary: `${status.filter(s => s.isSet).length}/${status.length} secrets are currently set in environment`, + }; + }, +}); + +registerTool({ + name: 'secrets.rotate', + description: + 'Rotate a specific secret by fetching fresh value from Key Vault and updating environment. Requires admin role.', + requiredRole: 'admin', + inputSchema: z.object({ + kvName: z.string().min(1).describe('Key Vault secret name to rotate'), + envVar: z.string().min(1).describe('Environment variable to update'), + productId: z.string().optional().describe('Optional product context for logging'), + }), + async execute(args, req) { + const { kvName, envVar, productId } = args; + const runId = `secret-rotate-${Date.now()}`; + + req.log.info({ runId, kvName, envVar, productId }, 'Starting secret rotation'); + + try { + // Store original value for rollback + const originalValue = process.env[envVar]; + + // Clear the env var to force refresh from Key Vault + delete process.env[envVar]; + + // Resolve fresh value from Key Vault + await resolveSecrets([{ kvName, envVar }]); + + const newValue = process.env[envVar]; + + if (!newValue) { + // Rollback on failure + if (originalValue) { + process.env[envVar] = originalValue; + } + throw new Error(`Failed to fetch secret ${kvName} from Key Vault`); + } + + const changed = originalValue !== newValue; + + req.log.info( + { + runId, + kvName, + envVar, + productId, + changed, + wasPreviouslySet: !!originalValue, + valueLength: newValue.length, + }, + changed ? 'Secret successfully rotated' : 'Secret value unchanged' + ); + + return { + runId, + kvName, + envVar, + productId, + changed, + wasPreviouslySet: !!originalValue, + valueLength: newValue.length, + summary: changed + ? `Successfully rotated ${kvName} โ†’ ${envVar}` + : `Secret ${kvName} value unchanged`, + }; + } catch (error) { + req.log.error( + { + runId, + kvName, + envVar, + productId, + error: error instanceof Error ? error.message : String(error), + }, + 'Secret rotation failed' + ); + + throw error; + } + }, +}); + +registerTool({ + name: 'secrets.validateMappings', + description: + 'Validate that all required secret mappings can be resolved from Key Vault. Requires admin role.', + requiredRole: 'admin', + inputSchema: z.object({ + productId: z.string().optional().describe('Optional product filter'), + dryRun: z + .boolean() + .optional() + .default(false) + .describe('If true, only validate without updating environment'), + }), + async execute(args, req) { + const { productId, dryRun } = args; + const runId = `secret-validate-${Date.now()}`; + + req.log.info({ runId, productId, dryRun }, 'Starting secret mapping validation'); + + try { + // Get mappings (reuse logic from listMappings) + const commonMappings: SecretMapping[] = [ + { kvName: 'bytelyst-cosmos-key', envVar: 'COSMOS_KEY' }, + { kvName: 'bytelyst-cosmos-endpoint', envVar: 'COSMOS_ENDPOINT' }, + { kvName: 'bytelyst-jwt-secret', envVar: 'JWT_SECRET' }, + { kvName: 'bytelyst-stripe-secret-key', envVar: 'STRIPE_SECRET_KEY' }, + { kvName: 'bytelyst-stripe-webhook-secret', envVar: 'STRIPE_WEBHOOK_SECRET' }, + ]; + + const productMappings: Record = { + lysnrai: [ + { kvName: 'lysnr-stripe-secret-key', envVar: 'STRIPE_SECRET_KEY' }, + { kvName: 'lysnr-stripe-webhook-secret', envVar: 'STRIPE_WEBHOOK_SECRET' }, + { kvName: 'lysnr-billing-internal-key', envVar: 'BILLING_INTERNAL_KEY' }, + { kvName: 'lysnr-blob-connection-string', envVar: 'AZURE_BLOB_CONNECTION_STRING' }, + { kvName: 'lysnr-blob-account-key', envVar: 'AZURE_BLOB_ACCOUNT_KEY' }, + ], + }; + + let mappings = [...commonMappings]; + if (productId && productMappings[productId]) { + mappings = [...mappings, ...productMappings[productId]]; + } + + const results = []; + let successCount = 0; + let failureCount = 0; + + for (const mapping of mappings) { + const originalValue = process.env[mapping.envVar]; + + try { + if (!dryRun) { + // Clear and resolve + delete process.env[mapping.envVar]; + await resolveSecrets([mapping]); + } + + const resolvedValue = process.env[mapping.envVar]; + + if (resolvedValue) { + successCount++; + results.push({ + kvName: mapping.kvName, + envVar: mapping.envVar, + status: 'success', + valueLength: resolvedValue.length, + wasPreviouslySet: !!originalValue, + changed: !dryRun && originalValue !== resolvedValue, + }); + } else { + failureCount++; + results.push({ + kvName: mapping.kvName, + envVar: mapping.envVar, + status: 'failed', + error: 'Secret not found in Key Vault or empty', + wasPreviouslySet: !!originalValue, + }); + + // Restore original value on failure + if (originalValue && !dryRun) { + process.env[mapping.envVar] = originalValue; + } + } + } catch (error) { + failureCount++; + results.push({ + kvName: mapping.kvName, + envVar: mapping.envVar, + status: 'error', + error: error instanceof Error ? error.message : String(error), + wasPreviouslySet: !!originalValue, + }); + + // Restore original value on error + if (originalValue && !dryRun) { + process.env[mapping.envVar] = originalValue; + } + } + } + + const summary = dryRun + ? `[DRY RUN] Validation complete: ${successCount}/${mappings.length} secrets resolvable` + : `Validation complete: ${successCount}/${mappings.length} secrets validated`; + + req.log.info( + { runId, productId, dryRun, successCount, failureCount, total: mappings.length }, + 'Secret validation completed' + ); + + return { + runId, + productId, + dryRun, + totalMappings: mappings.length, + successCount, + failureCount, + results, + summary, + }; + } catch (error) { + req.log.error( + { runId, productId, dryRun, error: error instanceof Error ? error.message : String(error) }, + 'Secret validation failed' + ); + + throw error; + } + }, +}); diff --git a/services/mcp-server/src/server.ts b/services/mcp-server/src/server.ts index ce5cfaa7..4ba8c388 100644 --- a/services/mcp-server/src/server.ts +++ b/services/mcp-server/src/server.ts @@ -71,6 +71,10 @@ import './modules/tracker/tracker-tools.js'; import './modules/platform/ops-tools.js'; import './modules/platform/webhooks-tools.js'; import './modules/platform/sdk-tools.js'; +import './modules/platform/secrets-tools.js'; +import './modules/platform/experiments-tools.js'; +import './modules/dev/data-generator-tools.js'; +import './modules/dev/changelog-tools.js'; const app = await createServiceApp({ name: 'mcp-server',