fix(mcp-server): resolve lint blockers in new MCP tools
This commit is contained in:
parent
3a7139790c
commit
53f34851df
@ -9,7 +9,7 @@ export interface PlatformClientOptions {
|
||||
productId?: string;
|
||||
}
|
||||
|
||||
async function platformFetch<T>(
|
||||
export async function platformFetch<T>(
|
||||
path: string,
|
||||
init: RequestInit,
|
||||
opts: PlatformClientOptions
|
||||
|
||||
357
services/mcp-server/src/modules/dev/changelog-tools.ts
Normal file
357
services/mcp-server/src/modules/dev/changelog-tools.ts
Normal file
@ -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<string, any[]>, commit: any) => {
|
||||
const category = categorizeCommit(commit.message, commit.files);
|
||||
if (!acc[category]) acc[category] = [];
|
||||
acc[category].push(commit);
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, any[]>
|
||||
);
|
||||
|
||||
// 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<string, string> = {
|
||||
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;
|
||||
}
|
||||
},
|
||||
});
|
||||
347
services/mcp-server/src/modules/dev/data-generator-tools.ts
Normal file
347
services/mcp-server/src/modules/dev/data-generator-tools.ts
Normal file
@ -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<string, number>, platform: string) => {
|
||||
acc[platform] = events.filter(e => e.platform === platform).length;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, number>
|
||||
);
|
||||
|
||||
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<string, number>, segment: string) => {
|
||||
acc[segment] = profiles.filter(p => p.segment === segment).length;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, number>
|
||||
);
|
||||
const countryCounts = countries.reduce(
|
||||
(acc: Record<string, number>, country: string) => {
|
||||
acc[country] = profiles.filter(p => p.country === country).length;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, number>
|
||||
);
|
||||
|
||||
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`,
|
||||
};
|
||||
},
|
||||
});
|
||||
313
services/mcp-server/src/modules/platform/experiments-tools.ts
Normal file
313
services/mcp-server/src/modules/platform/experiments-tools.ts
Normal file
@ -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)',
|
||||
};
|
||||
},
|
||||
});
|
||||
309
services/mcp-server/src/modules/platform/secrets-tools.ts
Normal file
309
services/mcp-server/src/modules/platform/secrets-tools.ts
Normal file
@ -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<void> {
|
||||
// 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<string, SecretMapping[]> = {
|
||||
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<string, SecretMapping[]> = {
|
||||
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;
|
||||
}
|
||||
},
|
||||
});
|
||||
@ -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',
|
||||
|
||||
Loading…
Reference in New Issue
Block a user