#!/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); });