fix(mcp-server): resolve lint blockers in new MCP tools

This commit is contained in:
saravanakumardb1 2026-03-05 22:30:39 -08:00
parent 3a7139790c
commit 53f34851df
6 changed files with 1331 additions and 1 deletions

View File

@ -9,7 +9,7 @@ export interface PlatformClientOptions {
productId?: string;
}
async function platformFetch<T>(
export async function platformFetch<T>(
path: string,
init: RequestInit,
opts: PlatformClientOptions

View 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;
}
},
});

View 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`,
};
},
});

View 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)',
};
},
});

View 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;
}
},
});

View File

@ -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',