#!/usr/bin/env node /** * Redmine MCP server — zero-dependency stdio JSON-RPC implementation. * * Env: * REDMINE_URL Base URL of the Redmine instance (e.g. https://redmine.example.com) * REDMINE_API_KEY API key (Account → API access key; REST API must be enabled by the admin) * * API docs: https://www.redmine.org/projects/redmine/wiki/rest_api */ 'use strict'; const SERVER_INFO = { name: 'redmine-mcp', version: '0.1.0' }; const PROTOCOL_VERSION = '2024-11-05'; const REDMINE_URL = (process.env.REDMINE_URL || '').replace(/\/+$/, ''); const REDMINE_API_KEY = process.env.REDMINE_API_KEY || ''; // --------------------------------------------------------------------------- // Redmine REST client // --------------------------------------------------------------------------- function requireConfig() { if (!REDMINE_URL) { throw new Error( 'REDMINE_URL is not configured. Set it to your Redmine base URL (e.g. https://redmine.example.com).' ); } if (!REDMINE_API_KEY) { throw new Error( 'REDMINE_API_KEY is not configured. Find your key under "My account" → "API access key" in Redmine ' + '(the administrator must enable the REST API in Administration → Settings → API).' ); } } function buildQuery(params) { const qs = new URLSearchParams(); for (const [key, value] of Object.entries(params || {})) { if (value === undefined || value === null || value === '') continue; qs.set(key, String(value)); } const s = qs.toString(); return s ? `?${s}` : ''; } async function redmine(method, path, { query, body } = {}) { requireConfig(); const url = `${REDMINE_URL}${path}${buildQuery(query)}`; const headers = { 'X-Redmine-API-Key': REDMINE_API_KEY }; if (body !== undefined) headers['Content-Type'] = 'application/json'; const res = await fetch(url, { method, headers, body: body !== undefined ? JSON.stringify(body) : undefined, }); const text = await res.text(); if (!res.ok) { let detail = text; try { const parsed = JSON.parse(text); if (parsed.errors) detail = parsed.errors.join('; '); } catch { /* keep raw text */ } const hints = { 401: 'Authentication failed — check REDMINE_API_KEY.', 403: 'Forbidden — your Redmine account lacks permission for this action.', 404: 'Not found — check the id/identifier (or the REST API may be disabled).', 422: 'Validation failed.', }; const hint = hints[res.status] ? ` ${hints[res.status]}` : ''; throw new Error(`Redmine API ${res.status} ${res.statusText} for ${method} ${path}.${hint}${detail ? ` Details: ${detail}` : ''}`); } if (!text) return { success: true, status: res.status }; try { return JSON.parse(text); } catch { return { success: true, status: res.status, raw: text.slice(0, 2000) }; } } // Strip undefined values so we send clean payloads. function compact(obj) { const out = {}; for (const [k, v] of Object.entries(obj)) { if (v !== undefined && v !== null) out[k] = v; } return out; } // --------------------------------------------------------------------------- // Tool definitions // --------------------------------------------------------------------------- const PAGINATION = { offset: { type: 'integer', description: 'Skip this many results (pagination).' }, limit: { type: 'integer', description: 'Number of results per page (default 25, max 100).' }, }; const CUSTOM_FIELDS = { type: 'array', description: 'Custom field values, e.g. [{"id": 1, "value": "foo"}]. Multiselect fields take an array value.', items: { type: 'object', properties: { id: { type: 'integer', description: 'Custom field id.' }, value: { description: 'Field value (string, or array of strings for multiselect).' }, }, required: ['id', 'value'], }, }; const ISSUE_FIELDS = { subject: { type: 'string', description: 'Issue subject/title.' }, description: { type: 'string', description: 'Issue description (Textile/Markdown per instance settings).' }, tracker_id: { type: 'integer', description: 'Tracker id (see redmine_list_metadata type=trackers).' }, status_id: { type: 'integer', description: 'Status id (see redmine_list_metadata type=issue_statuses).' }, priority_id: { type: 'integer', description: 'Priority id (see redmine_list_metadata type=issue_priorities).' }, assigned_to_id: { type: 'integer', description: 'User id to assign the issue to.' }, category_id: { type: 'integer', description: 'Issue category id.' }, fixed_version_id: { type: 'integer', description: 'Target version id (see redmine_list_versions).' }, parent_issue_id: { type: 'integer', description: 'Parent issue id (for subtasks).' }, start_date: { type: 'string', description: 'Start date, YYYY-MM-DD.' }, due_date: { type: 'string', description: 'Due date, YYYY-MM-DD.' }, estimated_hours: { type: 'number', description: 'Estimated hours.' }, done_ratio: { type: 'integer', description: 'Percent done, 0-100.' }, is_private: { type: 'boolean', description: 'Whether the issue is private.' }, custom_fields: CUSTOM_FIELDS, }; const TOOLS = [ { name: 'redmine_list_projects', description: 'List projects visible to the authenticated user.', inputSchema: { type: 'object', properties: { include: { type: 'string', description: 'Comma-separated extras: trackers, issue_categories, enabled_modules, time_entry_activities.', }, ...PAGINATION, }, }, handler: (a) => redmine('GET', '/projects.json', { query: { include: a.include, offset: a.offset, limit: a.limit } }), }, { name: 'redmine_get_project', description: 'Get a single project by numeric id or string identifier.', inputSchema: { type: 'object', properties: { id: { type: 'string', description: 'Project id or identifier (e.g. "42" or "my-project").' }, include: { type: 'string', description: 'Comma-separated extras: trackers, issue_categories, enabled_modules, time_entry_activities.', }, }, required: ['id'], }, handler: (a) => redmine('GET', `/projects/${encodeURIComponent(a.id)}.json`, { query: { include: a.include } }), }, { name: 'redmine_list_issues', description: 'List/search issues with filters. Returns open issues by default; use status_id="*" for all, "closed" for closed. ' + 'Date filters accept exact dates or operators like ">=2026-01-01" and "><2026-01-01|2026-01-31".', inputSchema: { type: 'object', properties: { project_id: { type: 'string', description: 'Project id or identifier.' }, issue_id: { type: 'string', description: 'Single issue id or comma-separated list of ids.' }, status_id: { type: 'string', description: '"open" (default), "closed", "*", or a status id.' }, assigned_to_id: { type: 'string', description: 'User id, or "me" for the authenticated user.' }, author_id: { type: 'string', description: 'User id of the author, or "me".' }, tracker_id: { type: 'integer', description: 'Filter by tracker id.' }, priority_id: { type: 'integer', description: 'Filter by priority id.' }, fixed_version_id: { type: 'integer', description: 'Filter by target version id.' }, parent_id: { type: 'integer', description: 'Filter by parent issue id.' }, query_id: { type: 'integer', description: 'Use a saved query id (see redmine_list_metadata type=queries).' }, subject: { type: 'string', description: 'Subject filter, e.g. "~login bug" for contains.' }, created_on: { type: 'string', description: 'e.g. ">=2026-07-01" or "><2026-07-01|2026-07-31".' }, updated_on: { type: 'string', description: 'e.g. ">=2026-07-01".' }, sort: { type: 'string', description: 'Sort column(s), ":desc" suffix for descending, e.g. "updated_on:desc".' }, include: { type: 'string', description: 'Comma-separated extras: attachments, relations.' }, extra_filters: { type: 'object', description: 'Any additional raw query params, e.g. {"cf_12": "value"} for custom field filters.', additionalProperties: { type: 'string' }, }, ...PAGINATION, }, }, handler: (a) => { const { extra_filters, ...rest } = a; return redmine('GET', '/issues.json', { query: { ...compact(rest), ...(extra_filters || {}) } }); }, }, { name: 'redmine_get_issue', description: 'Get a single issue by id, optionally with journals (comments/history), attachments, relations, children, watchers.', inputSchema: { type: 'object', properties: { id: { type: 'integer', description: 'Issue id.' }, include: { type: 'string', description: 'Comma-separated extras: journals, children, attachments, relations, changesets, watchers, allowed_statuses.', }, }, required: ['id'], }, handler: (a) => redmine('GET', `/issues/${a.id}.json`, { query: { include: a.include } }), }, { name: 'redmine_create_issue', description: 'Create a new issue in a project.', inputSchema: { type: 'object', properties: { project_id: { type: 'string', description: 'Project id or identifier.' }, watcher_user_ids: { type: 'array', items: { type: 'integer' }, description: 'User ids to add as watchers.' }, ...ISSUE_FIELDS, }, required: ['project_id', 'subject'], }, handler: (a) => redmine('POST', '/issues.json', { body: { issue: compact(a) } }), }, { name: 'redmine_update_issue', description: 'Update an issue: change any field and/or add a comment via "notes".', inputSchema: { type: 'object', properties: { id: { type: 'integer', description: 'Issue id.' }, project_id: { type: 'string', description: 'Move to another project (id or identifier).' }, notes: { type: 'string', description: 'Comment to add to the issue journal.' }, private_notes: { type: 'boolean', description: 'Make the added note private.' }, ...ISSUE_FIELDS, }, required: ['id'], }, handler: (a) => { const { id, ...fields } = a; return redmine('PUT', `/issues/${id}.json`, { body: { issue: compact(fields) } }); }, }, { name: 'redmine_delete_issue', description: 'Permanently delete an issue. Irreversible — prefer closing via redmine_update_issue unless deletion is explicitly wanted.', inputSchema: { type: 'object', properties: { id: { type: 'integer', description: 'Issue id.' } }, required: ['id'], }, handler: (a) => redmine('DELETE', `/issues/${a.id}.json`), }, { name: 'redmine_list_time_entries', description: 'List time entries, filterable by user, project, issue and date range.', inputSchema: { type: 'object', properties: { user_id: { type: 'string', description: 'User id, or "me".' }, project_id: { type: 'string', description: 'Project id or identifier.' }, issue_id: { type: 'integer', description: 'Issue id.' }, spent_on: { type: 'string', description: 'Exact date, YYYY-MM-DD.' }, from: { type: 'string', description: 'Range start, YYYY-MM-DD.' }, to: { type: 'string', description: 'Range end, YYYY-MM-DD.' }, ...PAGINATION, }, }, handler: (a) => redmine('GET', '/time_entries.json', { query: compact(a) }), }, { name: 'redmine_create_time_entry', description: 'Log time on an issue or a project (exactly one of issue_id/project_id is required).', inputSchema: { type: 'object', properties: { issue_id: { type: 'integer', description: 'Issue to log time on.' }, project_id: { type: 'string', description: 'Project to log time on (if not issue-bound).' }, hours: { type: 'number', description: 'Hours spent.' }, spent_on: { type: 'string', description: 'Date, YYYY-MM-DD (defaults to today).' }, activity_id: { type: 'integer', description: 'Activity id (see redmine_list_metadata type=time_entry_activities).' }, comments: { type: 'string', description: 'Short description (max 255 chars).' }, user_id: { type: 'integer', description: 'Log on behalf of this user (admin only).' }, }, required: ['hours'], }, handler: (a) => redmine('POST', '/time_entries.json', { body: { time_entry: compact(a) } }), }, { name: 'redmine_update_time_entry', description: 'Update an existing time entry.', inputSchema: { type: 'object', properties: { id: { type: 'integer', description: 'Time entry id.' }, issue_id: { type: 'integer' }, project_id: { type: 'string' }, hours: { type: 'number' }, spent_on: { type: 'string', description: 'YYYY-MM-DD.' }, activity_id: { type: 'integer' }, comments: { type: 'string' }, }, required: ['id'], }, handler: (a) => { const { id, ...fields } = a; return redmine('PUT', `/time_entries/${id}.json`, { body: { time_entry: compact(fields) } }); }, }, { name: 'redmine_delete_time_entry', description: 'Delete a time entry.', inputSchema: { type: 'object', properties: { id: { type: 'integer', description: 'Time entry id.' } }, required: ['id'], }, handler: (a) => redmine('DELETE', `/time_entries/${a.id}.json`), }, { name: 'redmine_list_users', description: 'List users (requires admin rights). Filter by status, name or group.', inputSchema: { type: 'object', properties: { status: { type: 'string', description: '1=active (default), 2=registered, 3=locked, or "" for all.' }, name: { type: 'string', description: 'Matches login, firstname, lastname or email.' }, group_id: { type: 'integer', description: 'Only users in this group.' }, ...PAGINATION, }, }, handler: (a) => redmine('GET', '/users.json', { query: compact(a) }), }, { name: 'redmine_get_user', description: 'Get a user by id, or the authenticated user with id="current".', inputSchema: { type: 'object', properties: { id: { type: 'string', description: 'User id, or "current" for the API key owner.' }, include: { type: 'string', description: 'Comma-separated extras: memberships, groups.' }, }, required: ['id'], }, handler: (a) => redmine('GET', `/users/${encodeURIComponent(a.id)}.json`, { query: { include: a.include } }), }, { name: 'redmine_list_versions', description: 'List versions (milestones/target versions) of a project.', inputSchema: { type: 'object', properties: { project_id: { type: 'string', description: 'Project id or identifier.' }, }, required: ['project_id'], }, handler: (a) => redmine('GET', `/projects/${encodeURIComponent(a.project_id)}/versions.json`), }, { name: 'redmine_list_metadata', description: 'List reference data needed for other calls: issue statuses, trackers, priorities, time entry activities, ' + 'custom fields, roles, saved queries, groups, or per-project issue categories and memberships.', inputSchema: { type: 'object', properties: { type: { type: 'string', enum: [ 'issue_statuses', 'trackers', 'issue_priorities', 'time_entry_activities', 'document_categories', 'custom_fields', 'roles', 'queries', 'groups', 'issue_categories', 'memberships', ], description: 'Which reference list to fetch.', }, project_id: { type: 'string', description: 'Project id or identifier — required for issue_categories and memberships.', }, ...PAGINATION, }, required: ['type'], }, handler: (a) => { const paths = { issue_statuses: '/issue_statuses.json', trackers: '/trackers.json', issue_priorities: '/enumerations/issue_priorities.json', time_entry_activities: '/enumerations/time_entry_activities.json', document_categories: '/enumerations/document_categories.json', custom_fields: '/custom_fields.json', roles: '/roles.json', queries: '/queries.json', groups: '/groups.json', }; if (a.type === 'issue_categories' || a.type === 'memberships') { if (!a.project_id) throw new Error(`project_id is required for type=${a.type}.`); return redmine('GET', `/projects/${encodeURIComponent(a.project_id)}/${a.type}.json`, { query: { offset: a.offset, limit: a.limit }, }); } return redmine('GET', paths[a.type], { query: { offset: a.offset, limit: a.limit } }); }, }, { name: 'redmine_search', description: 'Full-text search across issues, wiki pages, documents, etc.', inputSchema: { type: 'object', properties: { q: { type: 'string', description: 'Search query. Multiple space-separated tokens are ANDed.' }, scope: { type: 'string', description: 'Optional comma-separated result types to enable, e.g. "issues,wiki_pages". Default: all.', }, titles_only: { type: 'boolean', description: 'Match in titles only.' }, open_issues: { type: 'boolean', description: 'Restrict issue results to open issues.' }, ...PAGINATION, }, required: ['q'], }, handler: (a) => { const query = { q: a.q, offset: a.offset, limit: a.limit }; if (a.titles_only) query.titles_only = 1; if (a.open_issues) query.open_issues = 1; for (const s of (a.scope || '').split(',').map((x) => x.trim()).filter(Boolean)) { query[s] = 1; } return redmine('GET', '/search.json', { query }); }, }, { name: 'redmine_list_wiki_pages', description: 'List all wiki pages of a project.', inputSchema: { type: 'object', properties: { project_id: { type: 'string', description: 'Project id or identifier.' } }, required: ['project_id'], }, handler: (a) => redmine('GET', `/projects/${encodeURIComponent(a.project_id)}/wiki/index.json`), }, { name: 'redmine_get_wiki_page', description: 'Get a wiki page (optionally a specific old version) with its text.', inputSchema: { type: 'object', properties: { project_id: { type: 'string', description: 'Project id or identifier.' }, title: { type: 'string', description: 'Wiki page title.' }, version: { type: 'integer', description: 'Specific page version to fetch.' }, }, required: ['project_id', 'title'], }, handler: (a) => { const base = `/projects/${encodeURIComponent(a.project_id)}/wiki/${encodeURIComponent(a.title)}`; const path = a.version ? `${base}/${a.version}.json` : `${base}.json`; return redmine('GET', path); }, }, { name: 'redmine_update_wiki_page', description: 'Create or update a wiki page (PUT is upsert in Redmine).', inputSchema: { type: 'object', properties: { project_id: { type: 'string', description: 'Project id or identifier.' }, title: { type: 'string', description: 'Wiki page title.' }, text: { type: 'string', description: 'Full page content (replaces existing content).' }, comments: { type: 'string', description: 'Edit comment for the page history.' }, version: { type: 'integer', description: 'Base version for optimistic locking (409 on stale edit).' }, }, required: ['project_id', 'title', 'text'], }, handler: (a) => redmine('PUT', `/projects/${encodeURIComponent(a.project_id)}/wiki/${encodeURIComponent(a.title)}.json`, { body: { wiki_page: compact({ text: a.text, comments: a.comments, version: a.version }) }, }), }, ]; const TOOL_MAP = new Map(TOOLS.map((t) => [t.name, t])); // --------------------------------------------------------------------------- // MCP stdio transport (newline-delimited JSON-RPC 2.0) // --------------------------------------------------------------------------- function send(message) { process.stdout.write(JSON.stringify(message) + '\n'); } function sendResult(id, result) { send({ jsonrpc: '2.0', id, result }); } function sendError(id, code, message) { send({ jsonrpc: '2.0', id, error: { code, message } }); } async function handleRequest(msg) { const { id, method, params } = msg; switch (method) { case 'initialize': sendResult(id, { protocolVersion: params?.protocolVersion || PROTOCOL_VERSION, capabilities: { tools: {} }, serverInfo: SERVER_INFO, }); return; case 'ping': sendResult(id, {}); return; case 'tools/list': sendResult(id, { tools: TOOLS.map(({ name, description, inputSchema }) => ({ name, description, inputSchema })), }); return; case 'tools/call': { const tool = TOOL_MAP.get(params?.name); if (!tool) { sendError(id, -32602, `Unknown tool: ${params?.name}`); return; } try { const result = await tool.handler(params.arguments || {}); sendResult(id, { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], isError: false, }); } catch (err) { sendResult(id, { content: [{ type: 'text', text: `Error: ${err.message}` }], isError: true, }); } return; } default: if (id !== undefined && id !== null) { sendError(id, -32601, `Method not found: ${method}`); } // Notifications (notifications/initialized, notifications/cancelled, ...) are ignored. } } let buffer = ''; process.stdin.setEncoding('utf8'); process.stdin.on('data', (chunk) => { buffer += chunk; let newline; while ((newline = buffer.indexOf('\n')) !== -1) { const line = buffer.slice(0, newline).trim(); buffer = buffer.slice(newline + 1); if (!line) continue; let msg; try { msg = JSON.parse(line); } catch { sendError(null, -32700, 'Parse error'); continue; } handleRequest(msg).catch((err) => { if (msg.id !== undefined && msg.id !== null) { sendError(msg.id, -32603, `Internal error: ${err.message}`); } }); } }); process.stdin.on('end', () => process.exit(0));