commit 62a24477489f8365ae3f3407bf6ad436e3c0b03a Author: hardelele Date: Wed Feb 18 15:52:47 2026 +0500 Initial commit: Redmine MCP server for Claude Code diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c2658d7 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +node_modules/ diff --git a/install.ps1 b/install.ps1 new file mode 100644 index 0000000..9fbee83 --- /dev/null +++ b/install.ps1 @@ -0,0 +1,32 @@ +# Install Redmine MCP server and register in .mcp.json +param( + [string]$McpFile = ".mcp.json" +) + +$ErrorActionPreference = "Stop" +$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$ServerPath = Join-Path $ScriptDir "redmine-mcp-server.mjs" + +Write-Host "Installing dependencies..." +npm install --prefix $ScriptDir + +Write-Host "Registering in $McpFile..." +$entry = @{ + type = "stdio" + command = "node" + args = @($ServerPath.Replace('\', '/')) +} + +if (Test-Path $McpFile) { + $cfg = Get-Content $McpFile -Raw | ConvertFrom-Json + if (-not $cfg.mcpServers) { + $cfg | Add-Member -NotePropertyName "mcpServers" -NotePropertyValue @{} + } + $cfg.mcpServers | Add-Member -NotePropertyName "redmine" -NotePropertyValue $entry -Force +} else { + $cfg = @{ mcpServers = @{ redmine = $entry } } +} + +$cfg | ConvertTo-Json -Depth 10 | Set-Content $McpFile -Encoding UTF8 + +Write-Host "Done. Redmine MCP server registered." diff --git a/install.sh b/install.sh new file mode 100644 index 0000000..0b7c5f2 --- /dev/null +++ b/install.sh @@ -0,0 +1,40 @@ +#!/bin/bash +# Install Redmine MCP server and register in .mcp.json +set -e + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +MCP_FILE="${1:-.mcp.json}" + +echo "Installing dependencies..." +npm install --prefix "$SCRIPT_DIR" + +echo "Registering in $MCP_FILE..." +if [ -f "$MCP_FILE" ]; then + node -e " + const fs = require('fs'); + const cfg = JSON.parse(fs.readFileSync('$MCP_FILE', 'utf8')); + cfg.mcpServers = cfg.mcpServers || {}; + cfg.mcpServers.redmine = { + type: 'stdio', + command: 'node', + args: ['$SCRIPT_DIR/redmine-mcp-server.mjs'] + }; + fs.writeFileSync('$MCP_FILE', JSON.stringify(cfg, null, 2) + '\n'); + " +else + node -e " + const fs = require('fs'); + const cfg = { + mcpServers: { + redmine: { + type: 'stdio', + command: 'node', + args: ['$SCRIPT_DIR/redmine-mcp-server.mjs'] + } + } + }; + fs.writeFileSync('$MCP_FILE', JSON.stringify(cfg, null, 2) + '\n'); + " +fi + +echo "Done. Redmine MCP server registered." diff --git a/package.json b/package.json new file mode 100644 index 0000000..5c8673b --- /dev/null +++ b/package.json @@ -0,0 +1,13 @@ +{ + "name": "reckue-redmine-mcp", + "version": "1.0.0", + "description": "Redmine MCP server for Claude Code", + "type": "module", + "scripts": { + "start": "node redmine-mcp-server.mjs" + }, + "dependencies": { + "@modelcontextprotocol/sdk": "^1.12.1", + "zod": "^3.24.4" + } +} diff --git a/redmine-api.mjs b/redmine-api.mjs new file mode 100644 index 0000000..b0dd9d9 --- /dev/null +++ b/redmine-api.mjs @@ -0,0 +1,228 @@ +// Redmine REST API utility +// Usage: node deploy/redmine-api.mjs [args...] + +// Allow self-signed / mismatched certs when calling via IP +process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0'; + +const BASE_URL = 'https://81.200.150.204'; +const API_KEY = 'fca900011c01bf5c83862a5107c4d798fb6a4ef8'; + +async function request(method, path, body) { + const url = `${BASE_URL}${path}`; + const opts = { + method, + headers: { + 'X-Redmine-API-Key': API_KEY, + 'Content-Type': 'application/json', + }, + }; + if (body) opts.body = JSON.stringify(body); + + const res = await fetch(url, opts); + const text = await res.text(); + if (!res.ok) { + throw new Error(`${res.status} ${res.statusText}: ${text}`); + } + return text ? JSON.parse(text) : null; +} + +// --- Projects --- + +export async function listProjects() { + const data = await request('GET', '/projects.json'); + return data.projects; +} + +export async function getProject(idOrIdentifier) { + const data = await request('GET', `/projects/${idOrIdentifier}.json`); + return data.project; +} + +export async function createProject(name, identifier, description = '') { + const data = await request('POST', '/projects.json', { + project: { name, identifier, description }, + }); + return data.project; +} + +// --- Issues --- + +export async function listIssues(projectIdentifier, params = {}) { + const query = new URLSearchParams(params); + if (projectIdentifier) query.set('project_id', projectIdentifier); + const data = await request('GET', `/issues.json?${query}`); + return data.issues; +} + +export async function getIssue(id) { + const data = await request('GET', `/issues/${id}.json`); + return data.issue; +} + +export async function createIssue(projectIdentifier, subject, description = '', extras = {}) { + const data = await request('POST', '/issues.json', { + issue: { + project_id: projectIdentifier, + subject, + description, + ...extras, + }, + }); + return data.issue; +} + +export async function updateIssue(id, fields) { + await request('PUT', `/issues/${id}.json`, { issue: fields }); + return { id, ...fields }; +} + +export async function deleteIssue(id) { + await request('DELETE', `/issues/${id}.json`); + return { id, deleted: true }; +} + +// --- Wiki --- + +export async function listWikiPages(projectId) { + const data = await request('GET', `/projects/${projectId}/wiki/index.json`); + return data.wiki_pages; +} + +export async function getWikiPage(projectId, title) { + const data = await request('GET', `/projects/${projectId}/wiki/${encodeURIComponent(title)}.json`); + return data.wiki_page; +} + +export async function putWikiPage(projectId, title, text, comments = '') { + const body = { wiki_page: { text } }; + if (comments) body.wiki_page.comments = comments; + await request('PUT', `/projects/${projectId}/wiki/${encodeURIComponent(title)}.json`, body); + return { project: projectId, title, updated: true }; +} + +export async function deleteWikiPage(projectId, title) { + await request('DELETE', `/projects/${projectId}/wiki/${encodeURIComponent(title)}.json`); + return { project: projectId, title, deleted: true }; +} + +// --- Trackers, Statuses, Priorities (reference data) --- + +export async function listTrackers() { + const data = await request('GET', '/trackers.json'); + return data.trackers; +} + +export async function listStatuses() { + const data = await request('GET', '/issue_statuses.json'); + return data.issue_statuses; +} + +// --- CLI --- + +const commands = { + 'list-projects': async () => { + const projects = await listProjects(); + console.table(projects.map(p => ({ id: p.id, name: p.name, identifier: p.identifier }))); + }, + + 'get-project': async ([id]) => { + const project = await getProject(id); + console.log(JSON.stringify(project, null, 2)); + }, + + 'create-project': async ([name, identifier, description]) => { + const project = await createProject(name, identifier, description); + console.log('Created project:', project.id, project.identifier); + }, + + 'list-issues': async ([project, ...rest]) => { + const params = {}; + for (const arg of rest) { + const [k, v] = arg.split('='); + params[k] = v; + } + const issues = await listIssues(project, params); + console.table(issues.map(i => ({ + id: i.id, + tracker: i.tracker?.name, + status: i.status?.name, + subject: i.subject, + }))); + }, + + 'get-issue': async ([id]) => { + const issue = await getIssue(id); + console.log(JSON.stringify(issue, null, 2)); + }, + + 'create-issue': async ([project, subject, description]) => { + const issue = await createIssue(project, subject, description); + console.log('Created issue:', issue.id, '-', issue.subject); + }, + + 'update-issue': async ([id, ...fields]) => { + const data = {}; + for (const f of fields) { + const [k, v] = f.split('='); + data[k] = v; + } + const result = await updateIssue(id, data); + console.log('Updated issue:', result.id); + }, + + 'delete-issue': async ([id]) => { + await deleteIssue(id); + console.log('Deleted issue:', id); + }, + + 'list-trackers': async () => { + const trackers = await listTrackers(); + console.table(trackers.map(t => ({ id: t.id, name: t.name }))); + }, + + 'list-statuses': async () => { + const statuses = await listStatuses(); + console.table(statuses.map(s => ({ id: s.id, name: s.name }))); + }, + + 'list-wiki': async ([project]) => { + const pages = await listWikiPages(project); + console.table(pages.map(p => ({ title: p.title, version: p.version, created: p.created_on }))); + }, + + 'get-wiki': async ([project, title]) => { + const page = await getWikiPage(project, title); + console.log(`# ${page.title} (v${page.version})\n`); + console.log(page.text); + }, + + 'put-wiki': async ([project, title, ...textParts]) => { + const text = textParts.join(' '); + const result = await putWikiPage(project, title, text, 'Updated via CLI'); + console.log('Updated wiki page:', result.title); + }, + + 'delete-wiki': async ([project, title]) => { + await deleteWikiPage(project, title); + console.log('Deleted wiki page:', title); + }, +}; + +// Only run CLI when this file is executed directly +const isMain = process.argv[1] && import.meta.url.endsWith(process.argv[1].replace(/\\/g, '/').replace(/^.*:/, '')); +if (isMain) { + const [command, ...args] = process.argv.slice(2); + + if (!command || !commands[command]) { + console.log('Usage: node deploy/redmine-api.mjs [args...]'); + console.log('Commands:', Object.keys(commands).join(', ')); + process.exit(1); + } + + try { + await commands[command](args); + } catch (e) { + console.error('Error:', e.message); + process.exit(1); + } +} diff --git a/redmine-mcp-server.mjs b/redmine-mcp-server.mjs new file mode 100644 index 0000000..5653f17 --- /dev/null +++ b/redmine-mcp-server.mjs @@ -0,0 +1,239 @@ +#!/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); +});