@pschroee/redmine-mcp 0.4.2 → 0.4.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/formatters/index.d.ts +2 -0
- package/dist/formatters/index.js +2 -0
- package/dist/formatters/issue.d.ts +12 -0
- package/dist/formatters/issue.js +128 -0
- package/dist/formatters/journals.d.ts +10 -0
- package/dist/formatters/journals.js +157 -0
- package/dist/server.js +1 -1
- package/dist/tools/core.js +38 -3
- package/package.json +3 -1
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { RedmineIssue } from "../redmine/types.js";
|
|
2
|
+
import { type NameLookup } from "./journals.js";
|
|
3
|
+
/**
|
|
4
|
+
* Format an issue as complete Markdown
|
|
5
|
+
*/
|
|
6
|
+
export declare function formatIssue(issue: RedmineIssue, lookup?: NameLookup): string;
|
|
7
|
+
/**
|
|
8
|
+
* Format an issue API response as Markdown
|
|
9
|
+
*/
|
|
10
|
+
export declare function formatIssueResponse(response: {
|
|
11
|
+
issue: RedmineIssue;
|
|
12
|
+
}, lookup?: NameLookup): string;
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { formatJournals } from "./journals.js";
|
|
2
|
+
/**
|
|
3
|
+
* Format a date string to readable format
|
|
4
|
+
*/
|
|
5
|
+
function formatDate(isoDate) {
|
|
6
|
+
return new Date(isoDate).toISOString().slice(0, 16).replace("T", " ");
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Format an issue as complete Markdown
|
|
10
|
+
*/
|
|
11
|
+
export function formatIssue(issue, lookup = {}) {
|
|
12
|
+
const lines = [];
|
|
13
|
+
// Title
|
|
14
|
+
lines.push(`# #${issue.id}: ${issue.subject}`);
|
|
15
|
+
lines.push("");
|
|
16
|
+
// Status line
|
|
17
|
+
const statusParts = [];
|
|
18
|
+
statusParts.push(`**Status:** ${issue.status.name}`);
|
|
19
|
+
statusParts.push(`**Priority:** ${issue.priority.name}`);
|
|
20
|
+
if (issue.tracker) {
|
|
21
|
+
statusParts.push(`**Tracker:** ${issue.tracker.name}`);
|
|
22
|
+
}
|
|
23
|
+
if (issue.assigned_to) {
|
|
24
|
+
statusParts.push(`**Assigned:** ${issue.assigned_to.name}`);
|
|
25
|
+
}
|
|
26
|
+
lines.push(statusParts.join(" | "));
|
|
27
|
+
lines.push("");
|
|
28
|
+
// Metadata table
|
|
29
|
+
lines.push("| Field | Value |");
|
|
30
|
+
lines.push("|-------|-------|");
|
|
31
|
+
lines.push(`| Project | ${issue.project.name} |`);
|
|
32
|
+
lines.push(`| Author | ${issue.author.name} |`);
|
|
33
|
+
lines.push(`| Created | ${formatDate(issue.created_on)} |`);
|
|
34
|
+
lines.push(`| Updated | ${formatDate(issue.updated_on)} |`);
|
|
35
|
+
if (issue.closed_on) {
|
|
36
|
+
lines.push(`| Closed | ${formatDate(issue.closed_on)} |`);
|
|
37
|
+
}
|
|
38
|
+
if (issue.start_date) {
|
|
39
|
+
lines.push(`| Start Date | ${issue.start_date} |`);
|
|
40
|
+
}
|
|
41
|
+
if (issue.due_date) {
|
|
42
|
+
lines.push(`| Due Date | ${issue.due_date} |`);
|
|
43
|
+
}
|
|
44
|
+
if (issue.estimated_hours) {
|
|
45
|
+
lines.push(`| Estimated | ${issue.estimated_hours}h |`);
|
|
46
|
+
}
|
|
47
|
+
if (issue.spent_hours) {
|
|
48
|
+
lines.push(`| Spent | ${issue.spent_hours}h |`);
|
|
49
|
+
}
|
|
50
|
+
if (issue.done_ratio !== undefined) {
|
|
51
|
+
lines.push(`| Progress | ${issue.done_ratio}% |`);
|
|
52
|
+
}
|
|
53
|
+
if (issue.category) {
|
|
54
|
+
lines.push(`| Category | ${issue.category.name} |`);
|
|
55
|
+
}
|
|
56
|
+
if (issue.fixed_version) {
|
|
57
|
+
lines.push(`| Version | ${issue.fixed_version.name} |`);
|
|
58
|
+
}
|
|
59
|
+
if (issue.parent) {
|
|
60
|
+
lines.push(`| Parent | #${issue.parent.id} |`);
|
|
61
|
+
}
|
|
62
|
+
lines.push("");
|
|
63
|
+
// Custom fields
|
|
64
|
+
if (issue.custom_fields && issue.custom_fields.length > 0) {
|
|
65
|
+
lines.push("## Custom Fields");
|
|
66
|
+
lines.push("");
|
|
67
|
+
for (const cf of issue.custom_fields) {
|
|
68
|
+
const value = Array.isArray(cf.value) ? cf.value.join(", ") : cf.value;
|
|
69
|
+
if (value) {
|
|
70
|
+
lines.push(`- **${cf.name}:** ${value}`);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
lines.push("");
|
|
74
|
+
}
|
|
75
|
+
// Description
|
|
76
|
+
if (issue.description) {
|
|
77
|
+
lines.push("## Description");
|
|
78
|
+
lines.push("");
|
|
79
|
+
lines.push(issue.description);
|
|
80
|
+
lines.push("");
|
|
81
|
+
}
|
|
82
|
+
// Children
|
|
83
|
+
if (issue.children && issue.children.length > 0) {
|
|
84
|
+
lines.push("## Subtasks");
|
|
85
|
+
lines.push("");
|
|
86
|
+
for (const child of issue.children) {
|
|
87
|
+
lines.push(`- #${child.id}: ${child.subject} (${child.tracker.name})`);
|
|
88
|
+
}
|
|
89
|
+
lines.push("");
|
|
90
|
+
}
|
|
91
|
+
// Relations
|
|
92
|
+
if (issue.relations && issue.relations.length > 0) {
|
|
93
|
+
lines.push("## Relations");
|
|
94
|
+
lines.push("");
|
|
95
|
+
for (const rel of issue.relations) {
|
|
96
|
+
const target = rel.issue_id === issue.id ? rel.issue_to_id : rel.issue_id;
|
|
97
|
+
lines.push(`- ${rel.relation_type} #${target}`);
|
|
98
|
+
}
|
|
99
|
+
lines.push("");
|
|
100
|
+
}
|
|
101
|
+
// Attachments
|
|
102
|
+
if (issue.attachments && issue.attachments.length > 0) {
|
|
103
|
+
lines.push("## Attachments");
|
|
104
|
+
lines.push("");
|
|
105
|
+
for (const att of issue.attachments) {
|
|
106
|
+
lines.push(`- [${att.filename}](${att.content_url}) (${att.filesize} bytes)`);
|
|
107
|
+
}
|
|
108
|
+
lines.push("");
|
|
109
|
+
}
|
|
110
|
+
// Watchers
|
|
111
|
+
if (issue.watchers && issue.watchers.length > 0) {
|
|
112
|
+
lines.push("## Watchers");
|
|
113
|
+
lines.push("");
|
|
114
|
+
lines.push(issue.watchers.map(w => w.name).join(", "));
|
|
115
|
+
lines.push("");
|
|
116
|
+
}
|
|
117
|
+
// Journals (history)
|
|
118
|
+
if (issue.journals && issue.journals.length > 0) {
|
|
119
|
+
lines.push(formatJournals(issue.journals, lookup));
|
|
120
|
+
}
|
|
121
|
+
return lines.join("\n");
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Format an issue API response as Markdown
|
|
125
|
+
*/
|
|
126
|
+
export function formatIssueResponse(response, lookup = {}) {
|
|
127
|
+
return formatIssue(response.issue, lookup);
|
|
128
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { RedmineJournal } from "../redmine/types.js";
|
|
2
|
+
/**
|
|
3
|
+
* Lookup map for resolving IDs to names
|
|
4
|
+
* Key: field name (e.g., "status_id"), Value: map of ID -> name
|
|
5
|
+
*/
|
|
6
|
+
export type NameLookup = Record<string, Record<string, string>>;
|
|
7
|
+
/**
|
|
8
|
+
* Format an array of journal entries as Markdown
|
|
9
|
+
*/
|
|
10
|
+
export declare function formatJournals(journals: RedmineJournal[], lookup?: NameLookup): string;
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { diffLines } from "diff";
|
|
2
|
+
/**
|
|
3
|
+
* Fields that contain large text and should use diff formatting
|
|
4
|
+
*/
|
|
5
|
+
const LARGE_TEXT_FIELDS = ["description"];
|
|
6
|
+
/**
|
|
7
|
+
* Field name mappings for display (remove _id suffix)
|
|
8
|
+
*/
|
|
9
|
+
const FIELD_DISPLAY_NAMES = {
|
|
10
|
+
status_id: "status",
|
|
11
|
+
tracker_id: "tracker",
|
|
12
|
+
priority_id: "priority",
|
|
13
|
+
assigned_to_id: "assigned_to",
|
|
14
|
+
category_id: "category",
|
|
15
|
+
fixed_version_id: "version",
|
|
16
|
+
parent_id: "parent",
|
|
17
|
+
project_id: "project",
|
|
18
|
+
};
|
|
19
|
+
/**
|
|
20
|
+
* Format a date string to a readable format
|
|
21
|
+
*/
|
|
22
|
+
function formatDate(isoDate) {
|
|
23
|
+
const date = new Date(isoDate);
|
|
24
|
+
return date.toISOString().slice(0, 16).replace("T", " ");
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Generate a unified diff for large text changes
|
|
28
|
+
*/
|
|
29
|
+
function generateDiff(oldValue, newValue) {
|
|
30
|
+
const changes = diffLines(oldValue || "", newValue || "");
|
|
31
|
+
const lines = [];
|
|
32
|
+
for (const change of changes) {
|
|
33
|
+
const prefix = change.added ? "+" : change.removed ? "-" : " ";
|
|
34
|
+
const text = change.value.replace(/\n$/, "");
|
|
35
|
+
// Skip unchanged parts that are too long (context)
|
|
36
|
+
if (!change.added && !change.removed && text.length > 200) {
|
|
37
|
+
lines.push(" [...]");
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
for (const line of text.split("\n")) {
|
|
41
|
+
if (line || change.added || change.removed) {
|
|
42
|
+
lines.push(`${prefix} ${line}`);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return lines.join("\n");
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Resolve an ID value to a display string with name
|
|
50
|
+
*/
|
|
51
|
+
function resolveValue(fieldName, value, lookup) {
|
|
52
|
+
if (!value)
|
|
53
|
+
return "_(empty)_";
|
|
54
|
+
const fieldLookup = lookup[fieldName];
|
|
55
|
+
if (fieldLookup && fieldLookup[value]) {
|
|
56
|
+
return `${fieldLookup[value]} (${value})`;
|
|
57
|
+
}
|
|
58
|
+
// For parent_id, show as issue reference
|
|
59
|
+
if (fieldName === "parent_id") {
|
|
60
|
+
return `#${value}`;
|
|
61
|
+
}
|
|
62
|
+
return value;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Format a single journal detail (field change)
|
|
66
|
+
*/
|
|
67
|
+
function formatDetail(detail, lookup) {
|
|
68
|
+
const { property, name, old_value, new_value } = detail;
|
|
69
|
+
// Handle attachment additions/removals
|
|
70
|
+
if (property === "attachment") {
|
|
71
|
+
if (new_value && !old_value) {
|
|
72
|
+
return `- Added attachment: ${new_value}`;
|
|
73
|
+
}
|
|
74
|
+
if (old_value && !new_value) {
|
|
75
|
+
return `- Removed attachment: ${old_value}`;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
// Handle relation changes
|
|
79
|
+
if (property === "relation") {
|
|
80
|
+
if (new_value && !old_value) {
|
|
81
|
+
return `- Added relation: ${name} → #${new_value}`;
|
|
82
|
+
}
|
|
83
|
+
if (old_value && !new_value) {
|
|
84
|
+
return `- Removed relation: ${name} → #${old_value}`;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
// Handle custom fields
|
|
88
|
+
if (property === "cf") {
|
|
89
|
+
const fieldName = name;
|
|
90
|
+
if (!old_value && new_value) {
|
|
91
|
+
return `- ${fieldName}: _(empty)_ → ${new_value}`;
|
|
92
|
+
}
|
|
93
|
+
if (old_value && !new_value) {
|
|
94
|
+
return `- ${fieldName}: ${old_value} → _(empty)_`;
|
|
95
|
+
}
|
|
96
|
+
return `- ${fieldName}: ${old_value} → ${new_value}`;
|
|
97
|
+
}
|
|
98
|
+
// Handle attribute changes
|
|
99
|
+
if (property === "attr") {
|
|
100
|
+
// Large text fields get diff formatting
|
|
101
|
+
if (LARGE_TEXT_FIELDS.includes(name) && old_value && new_value) {
|
|
102
|
+
const diff = generateDiff(old_value, new_value);
|
|
103
|
+
return `- ${name}:\n\`\`\`diff\n${diff}\n\`\`\``;
|
|
104
|
+
}
|
|
105
|
+
// Get display name for the field
|
|
106
|
+
const displayName = FIELD_DISPLAY_NAMES[name] || name;
|
|
107
|
+
// Resolve ID fields to names
|
|
108
|
+
if (name.endsWith("_id") && lookup[name]) {
|
|
109
|
+
const oldDisplay = resolveValue(name, old_value, lookup);
|
|
110
|
+
const newDisplay = resolveValue(name, new_value, lookup);
|
|
111
|
+
return `- ${displayName}: ${oldDisplay} → ${newDisplay}`;
|
|
112
|
+
}
|
|
113
|
+
// Simple field changes
|
|
114
|
+
const oldDisplay = old_value || "_(empty)_";
|
|
115
|
+
const newDisplay = new_value || "_(empty)_";
|
|
116
|
+
return `- ${displayName}: ${oldDisplay} → ${newDisplay}`;
|
|
117
|
+
}
|
|
118
|
+
// Fallback for unknown property types
|
|
119
|
+
return `- ${property}.${name}: ${old_value || "_(empty)_"} → ${new_value || "_(empty)_"}`;
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Format a single journal entry as Markdown
|
|
123
|
+
*/
|
|
124
|
+
function formatJournalEntry(journal, lookup) {
|
|
125
|
+
const lines = [];
|
|
126
|
+
// Header with date and user
|
|
127
|
+
const date = formatDate(journal.created_on);
|
|
128
|
+
const user = journal.user.name;
|
|
129
|
+
const privateTag = journal.private_notes ? " 🔒" : "";
|
|
130
|
+
lines.push(`### ${date} - ${user}${privateTag}`);
|
|
131
|
+
lines.push("");
|
|
132
|
+
// Notes (if any)
|
|
133
|
+
if (journal.notes && journal.notes.trim()) {
|
|
134
|
+
lines.push(journal.notes.trim());
|
|
135
|
+
lines.push("");
|
|
136
|
+
}
|
|
137
|
+
// Details (field changes)
|
|
138
|
+
if (journal.details && journal.details.length > 0) {
|
|
139
|
+
lines.push("**Changes:**");
|
|
140
|
+
for (const detail of journal.details) {
|
|
141
|
+
lines.push(formatDetail(detail, lookup));
|
|
142
|
+
}
|
|
143
|
+
lines.push("");
|
|
144
|
+
}
|
|
145
|
+
return lines.join("\n");
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Format an array of journal entries as Markdown
|
|
149
|
+
*/
|
|
150
|
+
export function formatJournals(journals, lookup = {}) {
|
|
151
|
+
if (!journals || journals.length === 0) {
|
|
152
|
+
return "";
|
|
153
|
+
}
|
|
154
|
+
const header = `## History (${journals.length} entries)\n\n`;
|
|
155
|
+
const entries = journals.map(j => formatJournalEntry(j, lookup)).join("\n---\n\n");
|
|
156
|
+
return header + entries;
|
|
157
|
+
}
|
package/dist/server.js
CHANGED
|
@@ -3,7 +3,7 @@ import { registerTools } from "./tools/index.js";
|
|
|
3
3
|
export function createServer(redmineClient, toolGroups) {
|
|
4
4
|
const server = new McpServer({
|
|
5
5
|
name: "redmine-mcp",
|
|
6
|
-
version: "0.4.
|
|
6
|
+
version: "0.4.4",
|
|
7
7
|
});
|
|
8
8
|
registerTools(server, redmineClient, toolGroups);
|
|
9
9
|
return server;
|
package/dist/tools/core.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
+
import { formatIssueResponse } from "../formatters/index.js";
|
|
2
3
|
export function registerCoreTools(server, client) {
|
|
3
4
|
// === ISSUES ===
|
|
4
5
|
server.registerTool("list_issues", {
|
|
@@ -28,15 +29,49 @@ export function registerCoreTools(server, client) {
|
|
|
28
29
|
};
|
|
29
30
|
});
|
|
30
31
|
server.registerTool("get_issue", {
|
|
31
|
-
description: "Get details of a specific issue by ID",
|
|
32
|
+
description: "Get details of a specific issue by ID. Returns Markdown-formatted response.",
|
|
32
33
|
inputSchema: {
|
|
33
34
|
issue_id: z.number().describe("The issue ID"),
|
|
34
35
|
include: z.string().optional().describe("Include: attachments, relations, journals, watchers, children, changesets, allowed_statuses"),
|
|
35
36
|
},
|
|
36
37
|
}, async (params) => {
|
|
37
|
-
|
|
38
|
+
// Fetch issue and enumerations in parallel
|
|
39
|
+
const [result, statusesResult, trackersResult, prioritiesResult] = await Promise.all([
|
|
40
|
+
client.getIssue(params.issue_id, params.include),
|
|
41
|
+
client.listIssueStatuses(),
|
|
42
|
+
client.listTrackers(),
|
|
43
|
+
client.listIssuePriorities(),
|
|
44
|
+
]);
|
|
45
|
+
// Check if this is an error response
|
|
46
|
+
if ("error" in result) {
|
|
47
|
+
return {
|
|
48
|
+
content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
// Build lookup map for ID -> name resolution
|
|
52
|
+
const lookup = {
|
|
53
|
+
status_id: {},
|
|
54
|
+
tracker_id: {},
|
|
55
|
+
priority_id: {},
|
|
56
|
+
};
|
|
57
|
+
if (!("error" in statusesResult)) {
|
|
58
|
+
for (const s of statusesResult.issue_statuses) {
|
|
59
|
+
lookup.status_id[String(s.id)] = s.name;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
if (!("error" in trackersResult)) {
|
|
63
|
+
for (const t of trackersResult.trackers) {
|
|
64
|
+
lookup.tracker_id[String(t.id)] = t.name;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
if (!("error" in prioritiesResult)) {
|
|
68
|
+
for (const p of prioritiesResult.issue_priorities) {
|
|
69
|
+
lookup.priority_id[String(p.id)] = p.name;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
// Format response as Markdown
|
|
38
73
|
return {
|
|
39
|
-
content: [{ type: "text", text:
|
|
74
|
+
content: [{ type: "text", text: formatIssueResponse(result, lookup) }],
|
|
40
75
|
};
|
|
41
76
|
});
|
|
42
77
|
server.registerTool("create_issue", {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pschroee/redmine-mcp",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.4",
|
|
4
4
|
"description": "MCP server for Redmine - full API access with configurable tool groups",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -41,10 +41,12 @@
|
|
|
41
41
|
},
|
|
42
42
|
"dependencies": {
|
|
43
43
|
"@modelcontextprotocol/sdk": "^1.0.0",
|
|
44
|
+
"diff": "^8.0.2",
|
|
44
45
|
"zod": "^3.23.0"
|
|
45
46
|
},
|
|
46
47
|
"devDependencies": {
|
|
47
48
|
"@eslint/js": "^9.39.2",
|
|
49
|
+
"@types/diff": "^7.0.2",
|
|
48
50
|
"@types/node": "^20.0.0",
|
|
49
51
|
"dotenv": "^17.2.3",
|
|
50
52
|
"eslint": "^9.39.2",
|