rdms-mcp-server
Version:
MCP server for reading RDMS bug tracking system with AI image analysis
875 lines (784 loc) • 30.6 kB
JavaScript
#!/usr/bin/env node
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import {
CallToolRequestSchema,
ErrorCode,
ListToolsRequestSchema,
McpError,
} from '@modelcontextprotocol/sdk/types.js';
import axios from 'axios';
import * as cheerio from 'cheerio';
import fs from 'fs';
import path from 'path';
export class RDMSMCPServer {
static cookies = {};
constructor() {
this.server = new Server(
{
name: 'rdms-mcp-server',
version: '1.0.0',
},
{
capabilities: {
tools: {},
},
}
);
this.baseUrl = process.env.RDMS_BASE_URL || '';
this.username = process.env.RDMS_USERNAME || '';
this.password = process.env.RDMS_PASSWORD || '';
this.isLoggedIn = false;
this.client = axios.create({
timeout: 30000,
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
}
});
this.setupCookieInterceptors();
this.setupToolHandlers();
}
setupCookieInterceptors() {
// 请求拦截器:添加cookies
this.client.interceptors.request.use(config => {
const cookieString = Object.entries(RDMSMCPServer.cookies)
.map(([key, value]) => `${key}=${value}`)
.join('; ');
if (cookieString) {
config.headers.Cookie = cookieString;
}
return config;
});
// 响应拦截器:保存cookies
this.client.interceptors.response.use(response => {
const setCookieHeader = response.headers['set-cookie'];
if (setCookieHeader) {
setCookieHeader.forEach(cookie => {
const [nameValue] = cookie.split(';');
const [name, value] = nameValue.split('=');
if (name && value) {
RDMSMCPServer.cookies[name.trim()] = value.trim();
}
});
}
return response;
});
}
setupToolHandlers() {
this.server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: [
{
name: 'rdms_get_bug',
description: 'Get bug details by ID with image extraction. Returns bug information including image URLs but NOT image content. If you need to analyze image content, use the rdms_download_image tool with the returned image URLs.',
inputSchema: {
type: 'object',
properties: {
bugId: { type: 'string', description: 'Bug ID' }
},
required: ['bugId']
}
},
{
name: 'rdms_get_market_bug',
description: 'Get market bug details by ID with image extraction. Returns market bug information including image URLs but NOT image content. If you need to analyze image content, use the rdms_download_image tool with the returned image URLs.',
inputSchema: {
type: 'object',
properties: {
marketBugId: { type: 'string', description: 'Market bug ID' }
},
required: ['marketBugId']
}
},
{
name: 'rdms_get_my_bugs',
description: 'Get bugs assigned to current user',
inputSchema: {
type: 'object',
properties: {
status: { type: 'string', description: 'Filter by status', default: 'active' },
limit: { type: 'number', description: 'Max results', default: 20 }
}
}
},
{
name: 'rdms_get_my_market_bugs',
description: 'Get market bugs assigned to current user',
inputSchema: {
type: 'object',
properties: {
limit: { type: 'number', description: 'Max results', default: 20 }
}
}
},
{
name: 'rdms_download_image',
description: 'Download and optionally analyze image from RDMS system',
inputSchema: {
type: 'object',
properties: {
imageUrl: { type: 'string', description: 'Image URL from RDMS' },
filename: { type: 'string', description: 'Optional filename for saved image' },
analyze: { type: 'boolean', description: 'Whether to return image for AI analysis', default: true }
},
required: ['imageUrl']
}
}
]
}));
this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request.params;
try {
switch (name) {
case 'rdms_get_bug':
return { content: [{ type: 'text', text: JSON.stringify(await this.getBug(args.bugId)) }] };
case 'rdms_get_market_bug':
return { content: [{ type: 'text', text: JSON.stringify(await this.getMarketBug(args.marketBugId)) }] };
case 'rdms_get_my_bugs':
return { content: [{ type: 'text', text: JSON.stringify(await this.getMyBugs(args.status, args.limit)) }] };
case 'rdms_get_my_market_bugs':
return { content: [{ type: 'text', text: JSON.stringify(await this.getMyMarketBugs(args.limit)) }] };
case 'rdms_download_image':
return await this.downloadImage(args.imageUrl, args.filename, args.analyze);
default:
throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${name}`);
}
} catch (error) {
throw new McpError(ErrorCode.InternalError, `Tool execution failed: ${error.message}`);
}
});
}
async login(baseUrl, username, password) {
this.baseUrl = baseUrl;
this.username = username;
this.password = password;
try {
// 1. 获取登录页面
const loginPageResponse = await this.client.get(`${baseUrl}/index.php?m=user&f=login`);
const $ = cheerio.load(loginPageResponse.data);
const token = $('input[name="token"]').val();
// 2. 构建登录数据
const loginData = new URLSearchParams({
account: username,
password: password,
keepLogin: '1'
});
if (token) {
loginData.append('token', token);
}
// 3. 执行登录
const loginResponse = await this.client.post(`${baseUrl}/index.php?m=user&f=login`, loginData, {
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Referer': `${baseUrl}/index.php?m=user&f=login`
}
});
if (loginResponse.data.includes("self.location='/'") || loginResponse.data.length < 200) {
this.isLoggedIn = true;
console.log(`Auto-logged in to ${baseUrl} as ${username}`);
return { success: true, message: `Successfully logged in to RDMS system at ${baseUrl}` };
} else {
throw new Error('Login failed - invalid credentials or response');
}
} catch (error) {
this.isLoggedIn = false;
return { success: false, error: error.message };
}
}
async autoLogin() {
if (this.baseUrl && this.username && this.password) {
return await this.login(this.baseUrl, this.username, this.password);
}
return { success: false, error: 'Missing login credentials' };
}
async ensureLoggedIn() {
if (!this.isLoggedIn && this.baseUrl && this.username && this.password) {
await this.autoLogin();
}
if (!this.isLoggedIn) {
throw new Error('Not logged in. Please configure environment variables or use rdms_login tool.');
}
}
async getBug(bugId) {
await this.ensureLoggedIn();
try {
const response = await this.client.get(`${this.baseUrl}/index.php?m=bug&f=view&bugID=${bugId}`);
// 检查是否被重定向到登录页面
if (response.data.includes('login') && response.data.length < 500) {
throw new Error('Session expired, please login again');
}
const $ = cheerio.load(response.data);
// 初始化完整的响应结构
const bugInfo = {
id: bugId,
title: '',
status: '',
priority: '',
severity: '',
confirmed: '',
assignedTo: '',
reporter: '',
createdBy: '',
resolvedBy: '',
closedBy: '',
cc: '',
product: '',
project: '',
module: '',
version: '',
affectedVersion: '',
resolvedVersion: '',
os: '',
browser: '',
platformDevice: '',
bugType: '',
plan: '',
attribution: '',
attributionTeam: '',
valueAttribute: '',
activationCount: '',
activationDate: '',
probability: '',
commonIssue: '',
execution: '',
requirement: '',
task: '',
relatedBugs: '',
relatedCases: '',
deadline: '',
created: '',
updated: '',
lastModified: '',
steps: '',
description: '',
keywords: '',
solution: '',
images: []
};
// 提取标题 - 保留完整标题,只移除BUG编号和最后的系统名称
const fullTitle = $('title').text().trim();
if (fullTitle) {
// 移除开头的 "BUG #数字 " 和结尾的 " - 锐明RDMS"
bugInfo.title = fullTitle
.replace(/^BUG #\d+\s*/, '')
.replace(/\s*-\s*锐明RDMS\s*$/, '')
.replace(/\s*-\s*FT-V3\.X\s*-\s*锐明RDMS\s*$/, '')
.trim();
} else {
bugInfo.title = $('.page-title, h1').text().trim();
}
// 提取图片
$('img').each((i, img) => {
const src = $(img).attr('src');
if (src && !src.includes('data:') && !src.includes('base64')) {
const fullUrl = src.startsWith('http') ? src : `${this.baseUrl}${src}`;
bugInfo.images.push(fullUrl);
}
});
// 字段映射表 - 中文标签到英文字段的映射
const fieldMapping = {
'状态': 'status',
'Bug状态': 'status',
'优先级': 'priority',
'严重程度': 'severity',
'是否确认': 'confirmed',
'指派给': 'assignedTo',
'由谁创建': 'reporter',
'创建者': 'createdBy',
'报告人': 'reporter',
'解决者': 'resolvedBy',
'关闭者': 'closedBy',
'抄送给': 'cc',
'所属产品': 'product',
'所属项目': 'project',
'所属模块': 'module',
'影响版本': 'version',
'版本': 'affectedVersion',
'解决版本': 'resolvedVersion',
'操作系统': 'os',
'浏览器': 'browser',
'平台/设备': 'platformDevice',
'Bug类型': 'bugType',
'类型': 'bugType',
'计划': 'plan',
'所属计划': 'plan',
'归属': 'attribution',
'归属团队': 'attributionTeam',
'价值属性': 'valueAttribute',
'激活次数': 'activationCount',
'激活日期': 'activationDate',
'出现概率': 'probability',
'常见问题': 'commonIssue',
'执行': 'execution',
'需求': 'requirement',
'关联需求': 'requirement',
'任务': 'task',
'关联任务': 'task',
'相关Bug': 'relatedBugs',
'相关用例': 'relatedCases',
'截止日期': 'deadline',
'创建时间': 'created',
'更新时间': 'updated',
'最后修改': 'lastModified',
'重现步骤': 'steps',
'详细描述': 'description',
'描述': 'description',
'关键词': 'keywords',
'解决方案': 'solution'
};
// 从表格中提取字段
$('table tr, .table tr').each((i, row) => {
const $row = $(row);
const cells = $row.find('td, th');
if (cells.length >= 2) {
const label = cells.eq(0).text().trim();
const value = cells.eq(1).text().trim();
// 查找匹配的字段
for (const [chineseLabel, englishField] of Object.entries(fieldMapping)) {
if (label.includes(chineseLabel) && value) {
bugInfo[englishField] = value;
break;
}
}
}
});
// 使用CSS选择器进一步提取字段
Object.entries(fieldMapping).forEach(([chineseLabel, englishField]) => {
if (!bugInfo[englishField]) {
// 查找包含中文标签的元素
const elements = $(`*:contains("${chineseLabel}")`);
elements.each((i, el) => {
const $el = $(el);
const text = $el.text().trim();
// 如果元素只包含标签文本,查找相邻元素的值
if (text === chineseLabel || text === chineseLabel + ':' || text === chineseLabel + ':') {
const nextSibling = $el.next();
const parent = $el.parent();
const parentSiblings = parent.next();
let value = '';
if (nextSibling.length && nextSibling.text().trim()) {
value = nextSibling.text().trim();
} else if (parentSiblings.length && parentSiblings.text().trim()) {
value = parentSiblings.text().trim();
} else {
// 查找同一行的其他单元格
const row = $el.closest('tr');
const cells = row.find('td');
if (cells.length > 1) {
value = cells.eq(1).text().trim();
}
}
if (value && value !== chineseLabel) {
bugInfo[englishField] = value;
return false; // 找到后停止查找
}
}
});
}
});
// 特殊处理一些字段
// 提取步骤和描述(通常在较大的文本区域中)
if (!bugInfo.steps) {
const stepsArea = $('.steps, .reproduce-steps, [name*="steps"]').text().trim();
if (stepsArea) bugInfo.steps = stepsArea;
}
if (!bugInfo.description) {
const descArea = $('.description, .bug-description, [name*="desc"]').text().trim();
if (descArea) bugInfo.description = descArea;
}
// 提取历史记录
bugInfo.history = [];
$('.histories-list li').each((i, historyItem) => {
const $item = $(historyItem);
const historyText = $item.clone().children().remove().end().text().trim();
const comment = $item.find('.comment-content').text().trim();
if (historyText) {
// 解析历史记录文本,提取时间、操作人、操作内容
const historyMatch = historyText.match(/^(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2}),\s*由\s*(.+?)\s*(.+)$/);
const historyRecord = {
rawText: historyText,
comment: comment || null
};
if (historyMatch) {
historyRecord.time = historyMatch[1];
historyRecord.operator = historyMatch[2];
historyRecord.action = historyMatch[3];
}
bugInfo.history.push(historyRecord);
}
});
return bugInfo;
} catch (error) {
return { error: error.message };
}
}
async getMarketBug(marketBugId) {
await this.ensureLoggedIn();
try {
const response = await this.client.get(`${this.baseUrl}/index.php?m=bugmarket&f=view&bugID=${marketBugId}`);
const $ = cheerio.load(response.data);
const marketBugInfo = {
id: marketBugId,
title: '',
status: '',
priority: '',
severity: '',
assignedTo: '',
reporter: '',
product: '',
productLine: '',
productVersion: '',
productSystem: '',
project: '',
module: '',
version: '',
created: '',
updated: '',
region: '',
customerCode: '',
customerName: '',
expectedSolveDate: '',
problemLevel: '',
frontTechSupport: '',
defectDescription: '',
temporaryResponse: '',
solution: '',
defectAttribution: '',
defectType: '',
planFixTime: '',
problemAttributionTeam: '',
locationProblem: '',
confirmed: '',
solveDate: '',
closeDate: '',
submitPage: '',
images: [],
history: []
};
// 提取标题 - 优先从HTML title标签获取完整标题
const fullTitle = $('title').text().trim();
if (fullTitle) {
// 移除开头的 "BUG #数字 " 和结尾的 " - 锐明RDMS"
marketBugInfo.title = fullTitle
.replace(/^BUG #\d+\s*/, '')
.replace(/\s*-\s*锐明RDMS\s*$/, '')
.trim();
} else {
// 备选方案:从页面标题元素获取
const titleElement = $('.page-title .text');
if (titleElement.length) {
marketBugInfo.title = titleElement.text().trim();
} else {
marketBugInfo.title = $('.page-title').text().trim();
}
}
// 提取产品信息
$('.detail-title').each((i, titleEl) => {
const $title = $(titleEl);
const titleText = $title.text().trim();
const $content = $title.next('.detail-content');
if (titleText === '产品信息') {
$content.find('tr').each((j, row) => {
const $row = $(row);
const cells = $row.find('th, td');
for (let k = 0; k < cells.length; k += 2) {
const label = $(cells[k]).text().trim();
const value = $(cells[k + 1]).text().trim();
if (label === '产品线') marketBugInfo.productLine = value;
if (label === '所属产品') marketBugInfo.product = value;
if (label === '产品问题版本号') marketBugInfo.productVersion = value;
if (label === '产品系统组成') marketBugInfo.productSystem = value;
}
});
}
if (titleText === '客户信息') {
$content.find('tr').each((j, row) => {
const $row = $(row);
const cells = $row.find('th, td');
for (let k = 0; k < cells.length; k += 2) {
const label = $(cells[k]).text().trim();
const value = $(cells[k + 1]).text().trim();
if (label === '所属大区') marketBugInfo.region = value;
if (label === '客户代码') marketBugInfo.customerCode = value;
if (label === '客户名称') marketBugInfo.customerName = value;
if (label === '期望解决日期') marketBugInfo.expectedSolveDate = value;
}
});
}
if (titleText === '缺陷信息') {
$content.find('tr').each((j, row) => {
const $row = $(row);
const cells = $row.find('th, td');
for (let k = 0; k < cells.length; k += 2) {
const label = $(cells[k]).text().trim();
const value = $(cells[k + 1]).text().trim();
if (label === '问题级别') marketBugInfo.problemLevel = value;
if (label === '前方技术支持') marketBugInfo.frontTechSupport = value;
if (label === '缺陷描述') {
marketBugInfo.defectDescription = $(cells[k + 1]).find('.detail-content').text().trim() || value;
}
}
});
}
if (titleText === '解决方案') {
marketBugInfo.solution = $content.text().trim();
}
if (titleText === '缺陷归属') {
marketBugInfo.defectAttribution = $content.text().trim();
}
});
// 提取右侧基本信息 - 使用更宽泛的选择器
$('.side-col table tr, #legendBasicInfo table tr, .table-data tr').each((i, row) => {
const $row = $(row);
const label = $row.find('th').text().trim();
const value = $row.find('td').text().trim();
if (label === '缺陷状态') marketBugInfo.status = value;
if (label === '缺陷类型') marketBugInfo.defectType = value;
if (label === '优先级') marketBugInfo.priority = value;
if (label === '严重程度') marketBugInfo.severity = value;
if (label === '指派给') marketBugInfo.assignedTo = value;
if (label === '由谁创建') marketBugInfo.reporter = value;
if (label === '创建日期') marketBugInfo.created = value;
if (label === '最后修改') marketBugInfo.updated = value;
if (label === '计划修复时间') marketBugInfo.planFixTime = value;
if (label === '问题归属团队') marketBugInfo.problemAttributionTeam = value;
if (label === '定位问题') marketBugInfo.locationProblem = value;
if (label === '是否确认') marketBugInfo.confirmed = value;
if (label === '解决日期') marketBugInfo.solveDate = value;
if (label === '关闭日期') marketBugInfo.closeDate = value;
if (label === '提交页面') marketBugInfo.submitPage = value;
// 处理一些可能的字段变体
if (label.includes('所属项目') && value && value !== '') marketBugInfo.project = value;
if (label.includes('所属模块') && value && value !== '') marketBugInfo.module = value;
if (label.includes('版本') && value && value !== '') marketBugInfo.version = value;
});
// 提取历史记录
$('.histories-list li').each((i, historyItem) => {
const $item = $(historyItem);
const historyText = $item.clone().children().remove().end().text().trim();
const comment = $item.find('.comment-content').text().trim();
if (historyText) {
// 解析历史记录文本,提取时间、操作人、操作内容
const historyMatch = historyText.match(/^(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2}),\s*由\s*(.+?)\s*(.+)$/);
const historyRecord = {
rawText: historyText,
comment: comment || null
};
if (historyMatch) {
historyRecord.time = historyMatch[1];
historyRecord.operator = historyMatch[2];
historyRecord.action = historyMatch[3];
}
marketBugInfo.history.push(historyRecord);
}
});
// 提取图片
$('img').each((i, img) => {
const src = $(img).attr('src');
if (src && !src.includes('data:') && !src.includes('base64') && !src.includes('theme/') && !src.includes('icon')) {
const fullUrl = src.startsWith('http') ? src : `${this.baseUrl}${src}`;
marketBugInfo.images.push(fullUrl);
}
});
return marketBugInfo;
} catch (error) {
return { error: error.message };
}
}
async analyzeImages(imageUrls) {
const analysis = [];
for (const imageUrl of imageUrls) {
try {
// Download image data
const response = await this.client.get(imageUrl, { responseType: 'arraybuffer' });
const imageBuffer = Buffer.from(response.data);
const base64Image = imageBuffer.toString('base64');
// Determine image type
const contentType = response.headers['content-type'] || 'image/png';
const imageType = contentType.split('/')[1] || 'png';
analysis.push({
url: imageUrl,
type: imageType,
size: imageBuffer.length,
base64: `data:${contentType};base64,${base64Image}`,
description: `Image from RDMS (${imageType}, ${Math.round(imageBuffer.length / 1024)}KB)`
});
} catch (error) {
analysis.push({
url: imageUrl,
error: `Failed to download image: ${error.message}`
});
}
}
return analysis;
}
async downloadImage(imageUrl, filename, analyze = true) {
await this.ensureLoggedIn();
try {
const response = await this.client.get(imageUrl, { responseType: 'arraybuffer' });
const imageBuffer = Buffer.from(response.data);
const contentType = response.headers['content-type'] || 'image/png';
const imageType = contentType.split('/')[1] || 'png';
if (analyze) {
// Return image data for AI analysis
const base64Image = imageBuffer.toString('base64');
return {
content: [
{
type: 'text',
text: JSON.stringify({
success: true,
imageUrl,
type: imageType,
size: imageBuffer.length,
message: `Image downloaded successfully (${Math.round(imageBuffer.length / 1024)}KB)`
})
},
{
type: 'image',
data: base64Image,
mimeType: contentType
}
]
};
} else {
// Save to file if filename provided
if (filename) {
const filepath = path.resolve(filename);
fs.writeFileSync(filepath, imageBuffer);
return {
content: [{
type: 'text',
text: JSON.stringify({
success: true,
imageUrl,
savedTo: filepath,
type: imageType,
size: imageBuffer.length,
message: `Image saved to ${filepath}`
})
}]
};
} else {
return {
content: [{
type: 'text',
text: JSON.stringify({
success: true,
imageUrl,
type: imageType,
size: imageBuffer.length,
message: 'Image downloaded successfully'
})
}]
};
}
}
} catch (error) {
return {
content: [{
type: 'text',
text: JSON.stringify({
success: false,
error: error.message,
imageUrl
})
}]
};
}
}
async getMyBugs(status = 'active', limit = 20) {
await this.ensureLoggedIn();
try {
const myBugsUrl = `${this.baseUrl}/index.php?m=my&f=work&mode=bug&type=assignedTo`;
const response = await this.client.get(myBugsUrl);
return this.parseBugList(response.data, limit, '我的BUG');
} catch (error) {
return { success: false, error: error.message, bugs: [] };
}
}
async getMyMarketBugs(limit = 20) {
await this.ensureLoggedIn();
try {
const marketBugsUrl = `${this.baseUrl}/index.php?m=bugmarket&f=browse&productid=0&branch=0&browseType=assigntome`;
const response = await this.client.get(marketBugsUrl);
return this.parseBugList(response.data, limit, '市场Bug');
} catch (error) {
return { success: false, error: error.message, bugs: [] };
}
}
parseBugList(html, limit = 20, type = 'BUG') {
const $ = cheerio.load(html);
const bugs = [];
// 查找Bug链接 - 修正选择器
const bugLinks = $('a[href*="m=bug&f=view&bugID="]');
bugLinks.each((index, link) => {
if (index >= limit) return false;
const $link = $(link);
const href = $link.attr('href');
const title = $link.text().trim();
// 提取Bug ID - 修正正则表达式
const match = href.match(/bugID=(\d+)/);
const bugId = match ? match[1] : '';
// 获取当前行的其他信息
const $row = $link.closest('tr');
const severity = $row.find('.label-severity-custom').text().trim() ||
$row.find('[title*="严重程度"]').text().trim();
const priority = $row.find('.label-pri').text().trim();
const reporter = $row.find('td').eq(6).text().trim(); // 创建者列
const resolver = $row.find('td').eq(8).text().trim(); // 解决者列
const resolution = $row.find('td').eq(9).text().trim(); // 方案列
// 只处理有效的Bug
if (bugId && parseInt(bugId) > 0 && title && title.length > 0) {
bugs.push({
id: bugId,
title: title,
status: '', // 状态信息在这个页面中不直接显示
priority: priority,
severity: severity,
assignedTo: '', // 当前用户就是被指派人
reporter: reporter,
resolver: resolver,
resolution: resolution,
created: '',
url: href.startsWith('http') ? href : `${this.baseUrl}${href.replace(/^\.\//, '')}`
});
}
});
// 如果找到Bug,返回结果
if (bugs.length > 0) {
return {
success: true,
total: bugs.length,
bugs: bugs,
type: type,
message: `找到 ${bugs.length} 个${type}`
};
}
// 检查是否显示"暂时没有Bug"
const emptyTip = $('.table-empty-tip').text().trim();
if (emptyTip.includes('暂时没有Bug')) {
return {
success: true,
total: 0,
bugs: [],
type: type,
message: `暂无${type}`
};
}
// 默认返回空结果
return {
success: true,
total: 0,
bugs: [],
type: type,
message: `暂无${type}`
};
}
async run() {
const transport = new StdioServerTransport();
await this.server.connect(transport);
console.error('RDMS MCP server running on stdio');
}
}
const server = new RDMSMCPServer();
server.run().catch(console.error);