MCP Server 2.0 (#452)
* MCP Server 2.0 - Updated application version from 2.23.14 to 2.24.0 in package.json. - MCP Server improved with new features - Migrated functionality from Dev Tools to MCP Server - Improved getMonitoringProvider not to crash application when misconfigured
This commit is contained in:
committed by
GitHub
parent
059408a70a
commit
f3ac595d06
@@ -0,0 +1,99 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
type DepsUpgradeAdvisorDeps,
|
||||
createDepsUpgradeAdvisorService,
|
||||
} from '../deps-upgrade-advisor.service';
|
||||
|
||||
function createDeps(
|
||||
output: unknown,
|
||||
overrides: Partial<DepsUpgradeAdvisorDeps> = {},
|
||||
): DepsUpgradeAdvisorDeps {
|
||||
return {
|
||||
async executeCommand() {
|
||||
return {
|
||||
stdout: JSON.stringify(output),
|
||||
stderr: '',
|
||||
exitCode: 0,
|
||||
};
|
||||
},
|
||||
nowIso() {
|
||||
return '2026-02-09T00:00:00.000Z';
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('DepsUpgradeAdvisorService', () => {
|
||||
it('flags major updates as potentially breaking', async () => {
|
||||
const service = createDepsUpgradeAdvisorService(
|
||||
createDeps([
|
||||
{
|
||||
name: 'zod',
|
||||
current: '3.25.0',
|
||||
wanted: '3.26.0',
|
||||
latest: '4.0.0',
|
||||
workspace: 'root',
|
||||
dependencyType: 'dependencies',
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
const result = await service.advise({});
|
||||
const zod = result.recommendations.find((item) => item.package === 'zod');
|
||||
|
||||
expect(zod?.update_type).toBe('major');
|
||||
expect(zod?.potentially_breaking).toBe(true);
|
||||
expect(zod?.risk).toBe('high');
|
||||
});
|
||||
|
||||
it('prefers wanted for major updates when includeMajor is false', async () => {
|
||||
const service = createDepsUpgradeAdvisorService(
|
||||
createDeps([
|
||||
{
|
||||
name: 'example-lib',
|
||||
current: '1.2.0',
|
||||
wanted: '1.9.0',
|
||||
latest: '2.1.0',
|
||||
workspace: 'root',
|
||||
dependencyType: 'dependencies',
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
const result = await service.advise({});
|
||||
const item = result.recommendations[0];
|
||||
|
||||
expect(item?.recommended_target).toBe('1.9.0');
|
||||
});
|
||||
|
||||
it('filters out dev dependencies when requested', async () => {
|
||||
const service = createDepsUpgradeAdvisorService(
|
||||
createDeps([
|
||||
{
|
||||
name: 'vitest',
|
||||
current: '2.1.0',
|
||||
wanted: '2.1.8',
|
||||
latest: '2.1.8',
|
||||
workspace: 'root',
|
||||
dependencyType: 'devDependencies',
|
||||
},
|
||||
{
|
||||
name: 'zod',
|
||||
current: '3.25.0',
|
||||
wanted: '3.25.1',
|
||||
latest: '3.25.1',
|
||||
workspace: 'root',
|
||||
dependencyType: 'dependencies',
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
const result = await service.advise({
|
||||
state: { includeDevDependencies: false },
|
||||
});
|
||||
|
||||
expect(result.recommendations).toHaveLength(1);
|
||||
expect(result.recommendations[0]?.package).toBe('zod');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { registerDepsUpgradeAdvisorToolWithDeps } from '../index';
|
||||
import { DepsUpgradeAdvisorOutputSchema } from '../schema';
|
||||
|
||||
interface RegisteredTool {
|
||||
name: string;
|
||||
handler: (input: unknown) => Promise<Record<string, unknown>>;
|
||||
}
|
||||
|
||||
describe('registerDepsUpgradeAdvisorTool', () => {
|
||||
it('registers deps_upgrade_advisor and returns typed structured output', async () => {
|
||||
const tools: RegisteredTool[] = [];
|
||||
|
||||
const server = {
|
||||
registerTool(
|
||||
name: string,
|
||||
_config: Record<string, unknown>,
|
||||
handler: (input: unknown) => Promise<Record<string, unknown>>,
|
||||
) {
|
||||
tools.push({ name, handler });
|
||||
return {};
|
||||
},
|
||||
};
|
||||
|
||||
registerDepsUpgradeAdvisorToolWithDeps(server as never, {
|
||||
async executeCommand() {
|
||||
return {
|
||||
stdout: '[]',
|
||||
stderr: '',
|
||||
exitCode: 0,
|
||||
};
|
||||
},
|
||||
nowIso() {
|
||||
return '2026-02-09T00:00:00.000Z';
|
||||
},
|
||||
});
|
||||
|
||||
expect(tools).toHaveLength(1);
|
||||
expect(tools[0]?.name).toBe('deps_upgrade_advisor');
|
||||
|
||||
const result = await tools[0]!.handler({});
|
||||
const parsed = DepsUpgradeAdvisorOutputSchema.parse(
|
||||
result.structuredContent,
|
||||
);
|
||||
|
||||
expect(parsed.generated_at).toBeTruthy();
|
||||
expect(Array.isArray(parsed.recommendations)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,307 @@
|
||||
import type {
|
||||
DepsUpgradeAdvisorInput,
|
||||
DepsUpgradeAdvisorOutput,
|
||||
DepsUpgradeRecommendation,
|
||||
} from './schema';
|
||||
|
||||
interface CommandResult {
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
exitCode: number;
|
||||
}
|
||||
|
||||
interface OutdatedDependency {
|
||||
package: string;
|
||||
workspace: string;
|
||||
dependencyType: string;
|
||||
current: string;
|
||||
wanted: string;
|
||||
latest: string;
|
||||
}
|
||||
|
||||
export interface DepsUpgradeAdvisorDeps {
|
||||
executeCommand(command: string, args: string[]): Promise<CommandResult>;
|
||||
nowIso(): string;
|
||||
}
|
||||
|
||||
export function createDepsUpgradeAdvisorService(deps: DepsUpgradeAdvisorDeps) {
|
||||
return new DepsUpgradeAdvisorService(deps);
|
||||
}
|
||||
|
||||
export class DepsUpgradeAdvisorService {
|
||||
constructor(private readonly deps: DepsUpgradeAdvisorDeps) {}
|
||||
|
||||
async advise(
|
||||
input: DepsUpgradeAdvisorInput,
|
||||
): Promise<DepsUpgradeAdvisorOutput> {
|
||||
const includeMajor = input.state?.includeMajor ?? false;
|
||||
const maxPackages = input.state?.maxPackages ?? 50;
|
||||
const includeDevDependencies = input.state?.includeDevDependencies ?? true;
|
||||
const warnings: string[] = [];
|
||||
|
||||
const outdated = await this.getOutdatedDependencies(warnings);
|
||||
|
||||
const filtered = outdated.filter((item) => {
|
||||
if (includeDevDependencies) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return !item.dependencyType.toLowerCase().includes('dev');
|
||||
});
|
||||
|
||||
const recommendations = filtered
|
||||
.map((item) => toRecommendation(item, includeMajor))
|
||||
.sort(sortRecommendations)
|
||||
.slice(0, maxPackages);
|
||||
|
||||
const major = recommendations.filter(
|
||||
(item) => item.update_type === 'major',
|
||||
);
|
||||
const safe = recommendations.filter((item) => item.update_type !== 'major');
|
||||
|
||||
if (!includeMajor && major.length > 0) {
|
||||
warnings.push(
|
||||
`${major.length} major upgrades were excluded from immediate recommendations. Re-run with includeMajor=true to include them.`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
generated_at: this.deps.nowIso(),
|
||||
summary: {
|
||||
total_outdated: filtered.length,
|
||||
recommended_now: recommendations.filter((item) =>
|
||||
includeMajor ? true : item.update_type !== 'major',
|
||||
).length,
|
||||
major_available: filtered
|
||||
.map((item) => toRecommendation(item, true))
|
||||
.filter((item) => item.update_type === 'major').length,
|
||||
minor_or_patch_available: filtered
|
||||
.map((item) => toRecommendation(item, true))
|
||||
.filter(
|
||||
(item) =>
|
||||
item.update_type === 'minor' || item.update_type === 'patch',
|
||||
).length,
|
||||
},
|
||||
recommendations,
|
||||
grouped_commands: {
|
||||
safe_batch_command: buildBatchCommand(
|
||||
safe.map((item) => `${item.package}@${item.recommended_target}`),
|
||||
),
|
||||
major_batch_command: includeMajor
|
||||
? buildBatchCommand(
|
||||
major.map((item) => `${item.package}@${item.recommended_target}`),
|
||||
)
|
||||
: null,
|
||||
},
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
private async getOutdatedDependencies(warnings: string[]) {
|
||||
const attempts: string[][] = [
|
||||
['outdated', '--recursive', '--format', 'json'],
|
||||
['outdated', '--recursive', '--json'],
|
||||
];
|
||||
|
||||
let lastError: Error | null = null;
|
||||
|
||||
for (const args of attempts) {
|
||||
const result = await this.deps.executeCommand('pnpm', args);
|
||||
|
||||
if (!result.stdout.trim()) {
|
||||
if (result.exitCode === 0) {
|
||||
return [] as OutdatedDependency[];
|
||||
}
|
||||
|
||||
warnings.push(
|
||||
`pnpm ${args.join(' ')} returned no JSON output (exit code ${result.exitCode}).`,
|
||||
);
|
||||
lastError = new Error(result.stderr || 'Missing command output');
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
return normalizeOutdatedJson(JSON.parse(result.stdout));
|
||||
} catch (error) {
|
||||
lastError = error instanceof Error ? error : new Error('Invalid JSON');
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError ?? new Error('Unable to retrieve outdated dependencies');
|
||||
}
|
||||
}
|
||||
|
||||
function toRecommendation(
|
||||
dependency: OutdatedDependency,
|
||||
includeMajor: boolean,
|
||||
): DepsUpgradeRecommendation {
|
||||
const updateType = getUpdateType(dependency.current, dependency.latest);
|
||||
const risk =
|
||||
updateType === 'major' ? 'high' : updateType === 'minor' ? 'medium' : 'low';
|
||||
const target =
|
||||
updateType === 'major' && !includeMajor
|
||||
? dependency.wanted
|
||||
: dependency.latest;
|
||||
|
||||
return {
|
||||
package: dependency.package,
|
||||
workspace: dependency.workspace,
|
||||
dependency_type: dependency.dependencyType,
|
||||
current: dependency.current,
|
||||
wanted: dependency.wanted,
|
||||
latest: dependency.latest,
|
||||
update_type: updateType,
|
||||
risk,
|
||||
potentially_breaking: updateType === 'major',
|
||||
recommended_target: target,
|
||||
recommended_command: `pnpm up -r ${dependency.package}@${target}`,
|
||||
reason:
|
||||
updateType === 'major' && !includeMajor
|
||||
? 'Major version available and potentially breaking; recommended target is the highest non-major range match.'
|
||||
: `Recommended ${updateType} update based on current vs latest version.`,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeOutdatedJson(value: unknown): OutdatedDependency[] {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map(normalizeOutdatedItem).filter((item) => item !== null);
|
||||
}
|
||||
|
||||
if (isRecord(value)) {
|
||||
const rows: OutdatedDependency[] = [];
|
||||
|
||||
for (const [workspace, data] of Object.entries(value)) {
|
||||
if (!isRecord(data)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const [name, info] of Object.entries(data)) {
|
||||
if (!isRecord(info)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const current = readString(info, 'current');
|
||||
const wanted = readString(info, 'wanted');
|
||||
const latest = readString(info, 'latest');
|
||||
|
||||
if (!current || !wanted || !latest) {
|
||||
continue;
|
||||
}
|
||||
|
||||
rows.push({
|
||||
package: name,
|
||||
workspace,
|
||||
dependencyType: readString(info, 'dependencyType') ?? 'unknown',
|
||||
current,
|
||||
wanted,
|
||||
latest,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
function normalizeOutdatedItem(value: unknown): OutdatedDependency | null {
|
||||
if (!isRecord(value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const name =
|
||||
readString(value, 'name') ??
|
||||
readString(value, 'package') ??
|
||||
readString(value, 'pkgName');
|
||||
const current = readString(value, 'current');
|
||||
const wanted = readString(value, 'wanted');
|
||||
const latest = readString(value, 'latest');
|
||||
|
||||
if (!name || !current || !wanted || !latest) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
package: name,
|
||||
workspace:
|
||||
readString(value, 'workspace') ??
|
||||
readString(value, 'dependent') ??
|
||||
readString(value, 'location') ??
|
||||
'root',
|
||||
dependencyType:
|
||||
readString(value, 'dependencyType') ??
|
||||
readString(value, 'packageType') ??
|
||||
'unknown',
|
||||
current,
|
||||
wanted,
|
||||
latest,
|
||||
};
|
||||
}
|
||||
|
||||
function getUpdateType(current: string, latest: string) {
|
||||
const currentVersion = parseSemver(current);
|
||||
const latestVersion = parseSemver(latest);
|
||||
|
||||
if (!currentVersion || !latestVersion) {
|
||||
return 'unknown' as const;
|
||||
}
|
||||
|
||||
if (latestVersion.major > currentVersion.major) {
|
||||
return 'major' as const;
|
||||
}
|
||||
|
||||
if (latestVersion.minor > currentVersion.minor) {
|
||||
return 'minor' as const;
|
||||
}
|
||||
|
||||
if (latestVersion.patch > currentVersion.patch) {
|
||||
return 'patch' as const;
|
||||
}
|
||||
|
||||
return 'unknown' as const;
|
||||
}
|
||||
|
||||
function parseSemver(input: string) {
|
||||
const match = input.match(/(\d+)\.(\d+)\.(\d+)/);
|
||||
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
major: Number(match[1]),
|
||||
minor: Number(match[2]),
|
||||
patch: Number(match[3]),
|
||||
};
|
||||
}
|
||||
|
||||
function buildBatchCommand(upgrades: string[]) {
|
||||
if (upgrades.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return `pnpm up -r ${upgrades.join(' ')}`;
|
||||
}
|
||||
|
||||
function sortRecommendations(
|
||||
a: DepsUpgradeRecommendation,
|
||||
b: DepsUpgradeRecommendation,
|
||||
) {
|
||||
const rank: Record<DepsUpgradeRecommendation['risk'], number> = {
|
||||
high: 0,
|
||||
medium: 1,
|
||||
low: 2,
|
||||
};
|
||||
|
||||
return rank[a.risk] - rank[b.risk] || a.package.localeCompare(b.package);
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null;
|
||||
}
|
||||
|
||||
function readString(record: Record<string, unknown>, key: string) {
|
||||
const value = record[key];
|
||||
return typeof value === 'string' && value.length > 0 ? value : null;
|
||||
}
|
||||
122
packages/mcp-server/src/tools/deps-upgrade-advisor/index.ts
Normal file
122
packages/mcp-server/src/tools/deps-upgrade-advisor/index.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||
import { execFile } from 'node:child_process';
|
||||
import { promisify } from 'node:util';
|
||||
|
||||
import {
|
||||
type DepsUpgradeAdvisorDeps,
|
||||
createDepsUpgradeAdvisorService,
|
||||
} from './deps-upgrade-advisor.service';
|
||||
import {
|
||||
DepsUpgradeAdvisorInputSchema,
|
||||
DepsUpgradeAdvisorOutputSchema,
|
||||
} from './schema';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
export function registerDepsUpgradeAdvisorTool(server: McpServer) {
|
||||
return registerDepsUpgradeAdvisorToolWithDeps(
|
||||
server,
|
||||
createDepsUpgradeAdvisorDeps(),
|
||||
);
|
||||
}
|
||||
|
||||
export function registerDepsUpgradeAdvisorToolWithDeps(
|
||||
server: McpServer,
|
||||
deps: DepsUpgradeAdvisorDeps,
|
||||
) {
|
||||
const service = createDepsUpgradeAdvisorService(deps);
|
||||
|
||||
return server.registerTool(
|
||||
'deps_upgrade_advisor',
|
||||
{
|
||||
description:
|
||||
'Analyze outdated dependencies and return risk-bucketed upgrade recommendations',
|
||||
inputSchema: DepsUpgradeAdvisorInputSchema,
|
||||
outputSchema: DepsUpgradeAdvisorOutputSchema,
|
||||
},
|
||||
async (input) => {
|
||||
try {
|
||||
const parsed = DepsUpgradeAdvisorInputSchema.parse(input);
|
||||
const result = await service.advise(parsed);
|
||||
|
||||
return {
|
||||
structuredContent: result,
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify(result),
|
||||
},
|
||||
],
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
isError: true,
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `deps_upgrade_advisor failed: ${toErrorMessage(error)}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function createDepsUpgradeAdvisorDeps(): DepsUpgradeAdvisorDeps {
|
||||
const rootPath = process.cwd();
|
||||
|
||||
return {
|
||||
async executeCommand(command, args) {
|
||||
try {
|
||||
const result = await execFileAsync(command, args, {
|
||||
cwd: rootPath,
|
||||
maxBuffer: 1024 * 1024 * 10,
|
||||
});
|
||||
|
||||
return {
|
||||
stdout: result.stdout,
|
||||
stderr: result.stderr,
|
||||
exitCode: 0,
|
||||
};
|
||||
} catch (error) {
|
||||
if (isExecError(error)) {
|
||||
return {
|
||||
stdout: error.stdout ?? '',
|
||||
stderr: error.stderr ?? '',
|
||||
exitCode: error.code,
|
||||
};
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
nowIso() {
|
||||
return new Date().toISOString();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
interface ExecError extends Error {
|
||||
code: number;
|
||||
stdout?: string;
|
||||
stderr?: string;
|
||||
}
|
||||
|
||||
function isExecError(error: unknown): error is ExecError {
|
||||
return error instanceof Error && 'code' in error;
|
||||
}
|
||||
|
||||
function toErrorMessage(error: unknown) {
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
|
||||
return 'Unknown error';
|
||||
}
|
||||
|
||||
export {
|
||||
createDepsUpgradeAdvisorService,
|
||||
type DepsUpgradeAdvisorDeps,
|
||||
} from './deps-upgrade-advisor.service';
|
||||
export type { DepsUpgradeAdvisorOutput } from './schema';
|
||||
52
packages/mcp-server/src/tools/deps-upgrade-advisor/schema.ts
Normal file
52
packages/mcp-server/src/tools/deps-upgrade-advisor/schema.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { z } from 'zod/v3';
|
||||
|
||||
export const DepsUpgradeAdvisorInputSchema = z.object({
|
||||
state: z
|
||||
.object({
|
||||
includeMajor: z.boolean().optional(),
|
||||
maxPackages: z.number().int().min(1).max(200).optional(),
|
||||
includeDevDependencies: z.boolean().optional(),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export const DepsUpgradeRecommendationSchema = z.object({
|
||||
package: z.string(),
|
||||
workspace: z.string(),
|
||||
dependency_type: z.string(),
|
||||
current: z.string(),
|
||||
wanted: z.string(),
|
||||
latest: z.string(),
|
||||
update_type: z.enum(['major', 'minor', 'patch', 'unknown']),
|
||||
risk: z.enum(['high', 'medium', 'low']),
|
||||
potentially_breaking: z.boolean(),
|
||||
recommended_target: z.string(),
|
||||
recommended_command: z.string(),
|
||||
reason: z.string(),
|
||||
});
|
||||
|
||||
export const DepsUpgradeAdvisorOutputSchema = z.object({
|
||||
generated_at: z.string(),
|
||||
summary: z.object({
|
||||
total_outdated: z.number().int().min(0),
|
||||
recommended_now: z.number().int().min(0),
|
||||
major_available: z.number().int().min(0),
|
||||
minor_or_patch_available: z.number().int().min(0),
|
||||
}),
|
||||
recommendations: z.array(DepsUpgradeRecommendationSchema),
|
||||
grouped_commands: z.object({
|
||||
safe_batch_command: z.string().nullable(),
|
||||
major_batch_command: z.string().nullable(),
|
||||
}),
|
||||
warnings: z.array(z.string()),
|
||||
});
|
||||
|
||||
export type DepsUpgradeAdvisorInput = z.infer<
|
||||
typeof DepsUpgradeAdvisorInputSchema
|
||||
>;
|
||||
export type DepsUpgradeRecommendation = z.infer<
|
||||
typeof DepsUpgradeRecommendationSchema
|
||||
>;
|
||||
export type DepsUpgradeAdvisorOutput = z.infer<
|
||||
typeof DepsUpgradeAdvisorOutputSchema
|
||||
>;
|
||||
Reference in New Issue
Block a user