Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
import * as tools from "./tools/index.js";

const server = new McpServer({
Expand All @@ -14,6 +15,33 @@ Object.values(tools).forEach(tool => {
server.tool(tool.name, tool.description, tool.inputSchema, tool.handler);
});

// Register MCP Prompts
server.registerPrompt(
'upgrade-from-version',
{
title: 'Upgrade UI5 Web Components',
description: 'Generate a step-by-step migration checklist for upgrading UI5 Web Components between versions',
argsSchema: {
fromVersion: z.string().describe('Current version (e.g., "2.6.0")'),
toVersion: z.string().optional().default('latest').describe('Target version (e.g., "2.8.0" or "latest")'),
},
},
({ fromVersion, toVersion }) => ({
messages: [
{
role: 'user',
content: {
type: 'text',
text:
`I am upgrading UI5 Web Components from version ${fromVersion} to ${toVersion ?? 'latest'}. ` +
`Use get_upgrade_guidance to analyze breaking changes, then provide a step-by-step migration ` +
`checklist. Include code examples for any renamed APIs or changed component behaviors.`,
},
},
],
})
);

async function main() {
const transport = new StdioServerTransport();
await server.connect(transport);
Expand Down
203 changes: 203 additions & 0 deletions src/tools/get_upgrade_guidance/changelog_processor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
import { makeNpmRequest, NPM_REGISTRY_BASE, USER_AGENT } from '../../utils.js';
import type { NpmPackageData, VersionSection, ChangelogEntry, UpgradeGuidance } from '../../types.js';

const GITHUB_RAW_CHANGELOG_URL =
'https://raw.githubusercontent.com/SAP/ui5-webcomponents/main/CHANGELOG.md';

// # [2.21.0-rc.4](https://github.com/UI5/webcomponents/compare/...) (2026-04-02)
const VERSION_HEADING_RE = /^# \[(\d[\w.-]*)\].*?\((\d{4}-\d{2}-\d{2})\)/;
// ### Bug Fixes / ### Features / ### BREAKING CHANGES
const SUBSECTION_RE = /^### (.+)/;
// * **ui5-button:** description text ([#NNN](...)) ([hash](...))
const ENTRY_RE = /^\* \*\*([^*]+)\*\*:? ?(.+)/;

export async function fetchChangelog(): Promise<string | null> {
try {
const response = await fetch(GITHUB_RAW_CHANGELOG_URL, {
headers: { 'User-Agent': USER_AGENT },
});
if (!response.ok) return null;
return await response.text();
} catch {
return null;
}
}

export async function resolveLatestVersion(): Promise<string | null> {
const data = await makeNpmRequest<NpmPackageData>(
`${NPM_REGISTRY_BASE}/@ui5/webcomponents/latest`
);
return data?.version ?? null;
}

// Returns negative if a < b, 0 if equal, positive if a > b
// Handles pre-release versions: 2.0.0-rc.1 < 2.0.0
export function compareSemver(a: string, b: string): number {
const parseVersion = (v: string) => {
const [mainPart, prePart] = v.split('-', 2);
const nums = (mainPart ?? '').split('.').map(n => parseInt(n, 10) || 0);
return { nums, pre: prePart ?? null };
};

const pa = parseVersion(a);
const pb = parseVersion(b);

for (let i = 0; i < 3; i++) {
const diff = (pa.nums[i] ?? 0) - (pb.nums[i] ?? 0);
if (diff !== 0) return diff;
}

// Same numeric version: pre-release sorts before release
if (pa.pre && !pb.pre) return -1;
if (!pa.pre && pb.pre) return 1;
if (pa.pre && pb.pre) return pa.pre.localeCompare(pb.pre);
return 0;
}

// Range: (fromVersion, toVersion] — exclusive lower, inclusive upper
export function parseChangelogRange(
content: string,
fromVersion: string,
toVersion: string
): VersionSection[] {
const sections: VersionSection[] = [];
let currentSection: VersionSection | null = null;
type SubsectionType = 'breakingChanges' | 'features' | 'bugFixes' | null;
let currentSubsection: SubsectionType = null;

for (const line of content.split('\n')) {
const versionMatch = VERSION_HEADING_RE.exec(line);
if (versionMatch) {
const version = versionMatch[1]!;
const date = versionMatch[2]!;

// Stop when we reach a version at or below fromVersion
if (compareSemver(version, fromVersion) <= 0) {
break;
}

// Only collect versions within range: (fromVersion, toVersion]
if (compareSemver(version, toVersion) <= 0) {
currentSection = { version, date, breakingChanges: [], features: [], bugFixes: [] };
sections.push(currentSection);
currentSubsection = null;
} else {
// Version is above toVersion — not in range yet, keep scanning
currentSection = null;
currentSubsection = null;
}
continue;
}

if (!currentSection) continue;

const subsectionMatch = SUBSECTION_RE.exec(line);
if (subsectionMatch) {
const name = subsectionMatch[1]!.trim().toLowerCase();
if (name === 'breaking changes') {
currentSubsection = 'breakingChanges';
} else if (name === 'features') {
currentSubsection = 'features';
} else if (name === 'bug fixes') {
currentSubsection = 'bugFixes';
} else {
currentSubsection = null;
}
continue;
}

if (!currentSubsection) continue;

const entryMatch = ENTRY_RE.exec(line);
if (entryMatch) {
// Strip trailing PR/commit links: ([#NNN](link)) ([hash](link))
const rawDescription = entryMatch[2]!.replace(/\s*\(\[.*?\]\(.*?\)\)+\s*$/g, '').trim();
const entry: ChangelogEntry = {
component: entryMatch[1]!.replace(/:$/, '').trim(),
description: rawDescription,
};
currentSection[currentSubsection].push(entry);
}
}

return sections;
}

export function formatUpgradeGuidance(guidance: UpgradeGuidance): string {
const { fromVersion, toVersion, sections, hasBreakingChanges } = guidance;

const totalFeatures = sections.reduce((sum, s) => sum + s.features.length, 0);
const totalBugFixes = sections.reduce((sum, s) => sum + s.bugFixes.length, 0);
const totalBreaking = sections.reduce((sum, s) => sum + s.breakingChanges.length, 0);

const lines: string[] = [
`# UI5 Web Components Upgrade Guide: v${fromVersion} → v${toVersion}`,
'',
'## Summary',
'',
`- **Versions analyzed:** ${sections.map(s => `v${s.version}`).join(', ')}`,
`- **Breaking changes:** ${totalBreaking}`,
`- **New features:** ${totalFeatures}`,
`- **Bug fixes:** ${totalBugFixes}`,
'',
];

if (!hasBreakingChanges) {
lines.push('> No breaking changes found in this range. This upgrade should be safe.\n');
}

// Breaking changes section
const sectionsWithBreaking = sections.filter(s => s.breakingChanges.length > 0);
if (sectionsWithBreaking.length > 0) {
lines.push('## Breaking Changes\n');
for (const section of sectionsWithBreaking) {
lines.push(`### v${section.version} (${section.date})\n`);
// Group by component
const byComponent = new Map<string, string[]>();
for (const entry of section.breakingChanges) {
const existing = byComponent.get(entry.component) ?? [];
existing.push(entry.description);
byComponent.set(entry.component, existing);
}
for (const [component, descriptions] of byComponent) {
lines.push(`**${component}**`);
for (const desc of descriptions) {
lines.push(`- ${desc}`);
}
lines.push('');
}
}
}

// Features section
const sectionsWithFeatures = sections.filter(s => s.features.length > 0);
if (sectionsWithFeatures.length > 0) {
lines.push('## New Features\n');
for (const section of sectionsWithFeatures) {
lines.push(`### v${section.version} (${section.date})\n`);
for (const entry of section.features) {
lines.push(`- **${entry.component}:** ${entry.description}`);
}
lines.push('');
}
}

// Migration checklist — one item per unique component with breaking changes
if (hasBreakingChanges) {
const affectedComponents = new Set<string>();
for (const section of sections) {
for (const entry of section.breakingChanges) {
affectedComponents.add(entry.component);
}
}

lines.push('## Migration Checklist\n');
lines.push('Review and update each affected component in your codebase:\n');
for (const component of affectedComponents) {
lines.push(`- [ ] Update usage of \`${component}\``);
}
lines.push('');
}

return lines.join('\n');
}
93 changes: 93 additions & 0 deletions src/tools/get_upgrade_guidance/get_upgrade_guidance.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { z } from 'zod';
import { createTextResponse, handleToolError } from '../../utils.js';
import type { UpgradeGuidance } from '../../types.js';
import {
fetchChangelog,
parseChangelogRange,
formatUpgradeGuidance,
resolveLatestVersion,
compareSemver,
} from './changelog_processor.js';

const VERSION_RE = /^[\da-zA-Z.-]+$/;

type GetUpgradeGuidancePayload = {
fromVersion: string;
toVersion?: string;
};

export const getUpgradeGuidanceTool = {
name: 'get_upgrade_guidance',
description:
'Get upgrade guidance and breaking change summary for UI5 Web Components between two versions. Fetches the CHANGELOG from GitHub and returns a structured migration guide.',
inputSchema: {
fromVersion: z
.string()
.describe('Starting version to upgrade from (e.g., "2.6.0")'),
toVersion: z
.string()
.optional()
.default('latest')
.describe('Target version to upgrade to (e.g., "2.8.0" or "latest")'),
},
handler: async ({ fromVersion, toVersion = 'latest' }: GetUpgradeGuidancePayload) => {
try {
if (!VERSION_RE.test(fromVersion)) {
return createTextResponse(
`Access denied: fromVersion "${fromVersion}" contains invalid characters. Use a semver string (e.g., "2.6.0").`
);
}
if (!VERSION_RE.test(toVersion)) {
return createTextResponse(
`Access denied: toVersion "${toVersion}" contains invalid characters. Use a semver string (e.g., "2.8.0") or "latest".`
);
}

let resolvedToVersion = toVersion;
if (toVersion === 'latest') {
const latest = await resolveLatestVersion();
if (!latest) {
return createTextResponse(
'Could not resolve latest version from npm registry. Please specify an explicit version.'
);
}
resolvedToVersion = latest;
}

if (compareSemver(fromVersion, resolvedToVersion) >= 0) {
return createTextResponse(
`fromVersion "${fromVersion}" must be less than toVersion "${resolvedToVersion}".`
);
}

const changelog = await fetchChangelog();
if (!changelog) {
return createTextResponse(
'Could not fetch the UI5 Web Components CHANGELOG from GitHub. ' +
'Please try again or check https://github.com/SAP/ui5-webcomponents/blob/main/CHANGELOG.md'
);
}

const sections = parseChangelogRange(changelog, fromVersion, resolvedToVersion);

if (sections.length === 0) {
return createTextResponse(
`No changelog entries found between v${fromVersion} and v${resolvedToVersion}. ` +
'The versions may not exist in the current CHANGELOG (which covers recent releases only). ' +
'For older version history, see https://github.com/SAP/ui5-webcomponents/blob/main/CHANGELOG.md'
);
}

const guidance: UpgradeGuidance = {
fromVersion,
toVersion: resolvedToVersion,
sections,
hasBreakingChanges: sections.some(s => s.breakingChanges.length > 0),
};

return createTextResponse(formatUpgradeGuidance(guidance));
} catch (error) {
return handleToolError(error, 'Error retrieving upgrade guidance');
}
},
};
1 change: 1 addition & 0 deletions src/tools/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@ export { getComponentApiTool } from './get_component_api/get_component_api.js';
export { getGuidelinesTool } from './get_guidelines/get_guidelines.js';
export { listDocsTool } from './get_docs/list_docs.js';
export { getDocTool } from './get_docs/get_doc.js';
export { getUpgradeGuidanceTool } from './get_upgrade_guidance/get_upgrade_guidance.js';
20 changes: 20 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,3 +74,23 @@ export interface NpmPackageData {
tarball: string;
};
}

export interface ChangelogEntry {
component: string;
description: string;
}

export interface VersionSection {
version: string;
date: string;
breakingChanges: ChangelogEntry[];
features: ChangelogEntry[];
bugFixes: ChangelogEntry[];
}

export interface UpgradeGuidance {
fromVersion: string;
toVersion: string;
sections: VersionSection[];
hasBreakingChanges: boolean;
}
Loading
Loading