initial commit

This commit is contained in:
2026-07-23 19:33:31 +02:00
commit 32c33156a8
4 changed files with 680 additions and 0 deletions
@@ -0,0 +1,33 @@
{
"name": "redmine-mcp",
"displayName": "Redmine MCP",
"version": "0.1.0",
"description": "Redmine MCP szerver — issue-k, projektek, időrögzítés, felhasználók, wiki és keresés a Redmine REST API-n keresztül.",
"author": {
"name": "TTG",
"email": "rastasi@gmail.com"
},
"mcpServers": {
"redmine": {
"command": "node",
"args": ["${CLAUDE_PLUGIN_ROOT}/server/index.js"],
"env": {
"REDMINE_URL": "${user_config.redmine_url}",
"REDMINE_API_KEY": "${user_config.redmine_api_key}"
}
}
},
"userConfig": {
"redmine_url": {
"type": "string",
"title": "Redmine URL",
"description": "A Redmine példány alap URL-je, pl. https://redmine.example.com"
},
"redmine_api_key": {
"type": "string",
"title": "Redmine API kulcs",
"description": "Saját fiók → API hozzáférési kulcs (az adminnak engedélyeznie kell a REST API-t)",
"sensitive": true
}
}
}
+581
View File
@@ -0,0 +1,581 @@
#!/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));