Initial commit: Redmine MCP server for Claude Code
This commit is contained in:
commit
62a2447748
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
||||
node_modules/
|
||||
32
install.ps1
Normal file
32
install.ps1
Normal file
@ -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."
|
||||
40
install.sh
Normal file
40
install.sh
Normal file
@ -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."
|
||||
13
package.json
Normal file
13
package.json
Normal file
@ -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"
|
||||
}
|
||||
}
|
||||
228
redmine-api.mjs
Normal file
228
redmine-api.mjs
Normal file
@ -0,0 +1,228 @@
|
||||
// Redmine REST API utility
|
||||
// Usage: node deploy/redmine-api.mjs <command> [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 <command> [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);
|
||||
}
|
||||
}
|
||||
239
redmine-mcp-server.mjs
Normal file
239
redmine-mcp-server.mjs
Normal file
@ -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);
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user