240 lines
8.2 KiB
JavaScript
240 lines
8.2 KiB
JavaScript
#!/usr/bin/env node
|
|
// Redmine MCP Server — stdio transport
|
|
// Exposes Redmine API as MCP tools for Claude Code
|
|
|
|
process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
|
|
|
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
import { z } from 'zod';
|
|
import {
|
|
listIssues,
|
|
getIssue,
|
|
createIssue,
|
|
updateIssue,
|
|
listStatuses,
|
|
listTrackers,
|
|
} from './redmine-api.mjs';
|
|
|
|
const PROJECT = 'mek-constructor';
|
|
|
|
const server = new McpServer({
|
|
name: 'redmine',
|
|
version: '1.0.0',
|
|
});
|
|
|
|
// --- Tool: list issues ---
|
|
server.tool(
|
|
'redmine_list_issues',
|
|
'List issues in the Redmine project. Returns id, tracker, status, priority, subject, updated_on.',
|
|
{
|
|
status_id: z.enum(['open', 'closed', '*', '1', '2', '3', '4', '5', '6']).optional()
|
|
.describe('Filter by status: open (default), closed, * (all), or specific ID'),
|
|
tracker_id: z.string().optional()
|
|
.describe('Filter by tracker ID (1=Bug, 2=Feature, 4=Epic, 5=Task)'),
|
|
priority_id: z.string().optional()
|
|
.describe('Filter by priority ID (1=Low, 2=Normal, 3=High, 4=Urgent)'),
|
|
assigned_to_id: z.string().optional()
|
|
.describe('Filter by assignee ID ("me" for current user)'),
|
|
limit: z.string().optional()
|
|
.describe('Max results (default 25, max 100)'),
|
|
offset: z.string().optional()
|
|
.describe('Offset for pagination'),
|
|
sort: z.string().optional()
|
|
.describe('Sort field (e.g. "updated_on:desc", "priority:desc")'),
|
|
},
|
|
async (params) => {
|
|
try {
|
|
const query = {};
|
|
if (params.status_id) query.status_id = params.status_id;
|
|
if (params.tracker_id) query.tracker_id = params.tracker_id;
|
|
if (params.priority_id) query.priority_id = params.priority_id;
|
|
if (params.assigned_to_id) query.assigned_to_id = params.assigned_to_id;
|
|
if (params.limit) query.limit = params.limit;
|
|
if (params.offset) query.offset = params.offset;
|
|
if (params.sort) query.sort = params.sort;
|
|
|
|
const issues = await listIssues(PROJECT, query);
|
|
const rows = issues.map(i => ({
|
|
id: i.id,
|
|
tracker: i.tracker?.name,
|
|
status: i.status?.name,
|
|
priority: i.priority?.name,
|
|
subject: i.subject,
|
|
updated_on: i.updated_on,
|
|
done_ratio: i.done_ratio,
|
|
}));
|
|
return { content: [{ type: 'text', text: JSON.stringify(rows, null, 2) }] };
|
|
} catch (e) {
|
|
return { content: [{ type: 'text', text: `Error: ${e.message}` }], isError: true };
|
|
}
|
|
}
|
|
);
|
|
|
|
// --- Tool: get issue ---
|
|
server.tool(
|
|
'redmine_get_issue',
|
|
'Get full details of a Redmine issue by ID.',
|
|
{
|
|
issue_id: z.string().describe('Issue ID'),
|
|
},
|
|
async ({ issue_id }) => {
|
|
try {
|
|
const issue = await getIssue(issue_id);
|
|
return { content: [{ type: 'text', text: JSON.stringify(issue, null, 2) }] };
|
|
} catch (e) {
|
|
return { content: [{ type: 'text', text: `Error: ${e.message}` }], isError: true };
|
|
}
|
|
}
|
|
);
|
|
|
|
// --- Tool: create issue ---
|
|
server.tool(
|
|
'redmine_create_issue',
|
|
'Create a new issue in the Redmine project.',
|
|
{
|
|
subject: z.string().describe('Issue title'),
|
|
description: z.string().optional().describe('Issue description (textile/markdown)'),
|
|
tracker_id: z.number().optional().describe('Tracker: 1=Bug, 2=Feature, 4=Epic, 5=Task'),
|
|
priority_id: z.number().optional().describe('Priority: 1=Low, 2=Normal, 3=High, 4=Urgent'),
|
|
parent_issue_id: z.number().optional().describe('Parent issue ID for subtasks'),
|
|
assigned_to_id: z.number().optional().describe('Assignee user ID'),
|
|
},
|
|
async ({ subject, description, tracker_id, priority_id, parent_issue_id, assigned_to_id }) => {
|
|
try {
|
|
const extras = {};
|
|
if (tracker_id) extras.tracker_id = tracker_id;
|
|
if (priority_id) extras.priority_id = priority_id;
|
|
if (parent_issue_id) extras.parent_issue_id = parent_issue_id;
|
|
if (assigned_to_id) extras.assigned_to_id = assigned_to_id;
|
|
|
|
const issue = await createIssue(PROJECT, subject, description || '', extras);
|
|
return {
|
|
content: [{
|
|
type: 'text',
|
|
text: `Created issue #${issue.id}: ${issue.subject}\nURL: https://redmine.reckue.com/issues/${issue.id}`,
|
|
}],
|
|
};
|
|
} catch (e) {
|
|
return { content: [{ type: 'text', text: `Error: ${e.message}` }], isError: true };
|
|
}
|
|
}
|
|
);
|
|
|
|
// --- Tool: update issue ---
|
|
server.tool(
|
|
'redmine_update_issue',
|
|
'Update an existing Redmine issue (status, notes, progress, etc).',
|
|
{
|
|
issue_id: z.string().describe('Issue ID to update'),
|
|
status_id: z.number().optional().describe('Status: 1=New, 2=InProgress, 3=Resolved, 4=Feedback, 5=Closed, 6=Rejected'),
|
|
notes: z.string().optional().describe('Comment to add to the issue'),
|
|
done_ratio: z.number().optional().describe('Progress percentage (0-100)'),
|
|
priority_id: z.number().optional().describe('Priority: 1=Low, 2=Normal, 3=High, 4=Urgent'),
|
|
assigned_to_id: z.number().optional().describe('Assignee user ID'),
|
|
subject: z.string().optional().describe('Update issue title'),
|
|
},
|
|
async ({ issue_id, ...fields }) => {
|
|
try {
|
|
const update = {};
|
|
if (fields.status_id !== undefined) update.status_id = fields.status_id;
|
|
if (fields.notes !== undefined) update.notes = fields.notes;
|
|
if (fields.done_ratio !== undefined) update.done_ratio = fields.done_ratio;
|
|
if (fields.priority_id !== undefined) update.priority_id = fields.priority_id;
|
|
if (fields.assigned_to_id !== undefined) update.assigned_to_id = fields.assigned_to_id;
|
|
if (fields.subject !== undefined) update.subject = fields.subject;
|
|
|
|
await updateIssue(issue_id, update);
|
|
return {
|
|
content: [{
|
|
type: 'text',
|
|
text: `Updated issue #${issue_id}. Fields: ${Object.keys(update).join(', ')}`,
|
|
}],
|
|
};
|
|
} catch (e) {
|
|
return { content: [{ type: 'text', text: `Error: ${e.message}` }], isError: true };
|
|
}
|
|
}
|
|
);
|
|
|
|
// --- Tool: list statuses ---
|
|
server.tool(
|
|
'redmine_list_statuses',
|
|
'Get all available issue statuses (id + name).',
|
|
{},
|
|
async () => {
|
|
try {
|
|
const statuses = await listStatuses();
|
|
return { content: [{ type: 'text', text: JSON.stringify(statuses, null, 2) }] };
|
|
} catch (e) {
|
|
return { content: [{ type: 'text', text: `Error: ${e.message}` }], isError: true };
|
|
}
|
|
}
|
|
);
|
|
|
|
// --- Tool: list trackers ---
|
|
server.tool(
|
|
'redmine_list_trackers',
|
|
'Get all available issue trackers (id + name).',
|
|
{},
|
|
async () => {
|
|
try {
|
|
const trackers = await listTrackers();
|
|
return { content: [{ type: 'text', text: JSON.stringify(trackers, null, 2) }] };
|
|
} catch (e) {
|
|
return { content: [{ type: 'text', text: `Error: ${e.message}` }], isError: true };
|
|
}
|
|
}
|
|
);
|
|
|
|
// --- Tool: search issues ---
|
|
server.tool(
|
|
'redmine_search_issues',
|
|
'Search issues by text query in subject and description.',
|
|
{
|
|
query: z.string().describe('Search text'),
|
|
status_id: z.enum(['open', 'closed', '*']).optional()
|
|
.describe('Filter by status: open (default), closed, * (all)'),
|
|
},
|
|
async ({ query, status_id }) => {
|
|
try {
|
|
const params = {};
|
|
if (status_id) params.status_id = status_id;
|
|
|
|
// Redmine doesn't have a dedicated search endpoint for issues,
|
|
// so we fetch all and filter client-side
|
|
params.limit = '100';
|
|
params.status_id = status_id || '*';
|
|
|
|
const issues = await listIssues(PROJECT, params);
|
|
const lowerQuery = query.toLowerCase();
|
|
const matched = issues.filter(i =>
|
|
i.subject?.toLowerCase().includes(lowerQuery) ||
|
|
i.description?.toLowerCase().includes(lowerQuery)
|
|
);
|
|
|
|
const rows = matched.map(i => ({
|
|
id: i.id,
|
|
tracker: i.tracker?.name,
|
|
status: i.status?.name,
|
|
subject: i.subject,
|
|
}));
|
|
return { content: [{ type: 'text', text: JSON.stringify(rows, null, 2) }] };
|
|
} catch (e) {
|
|
return { content: [{ type: 'text', text: `Error: ${e.message}` }], isError: true };
|
|
}
|
|
}
|
|
);
|
|
|
|
// --- Start server ---
|
|
async function main() {
|
|
const transport = new StdioServerTransport();
|
|
await server.connect(transport);
|
|
console.error('Redmine MCP server running on stdio');
|
|
}
|
|
|
|
main().catch((e) => {
|
|
console.error('Fatal:', e);
|
|
process.exit(1);
|
|
});
|