@pschroee/redmine-mcp 0.4.3 → 0.4.5

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.
@@ -1,2 +1,2 @@
1
- export { formatJournals } from "./journals.js";
1
+ export { formatJournals, type NameLookup } from "./journals.js";
2
2
  export { formatIssue, formatIssueResponse } from "./issue.js";
@@ -1,11 +1,12 @@
1
1
  import type { RedmineIssue } from "../redmine/types.js";
2
+ import { type NameLookup } from "./journals.js";
2
3
  /**
3
4
  * Format an issue as complete Markdown
4
5
  */
5
- export declare function formatIssue(issue: RedmineIssue): string;
6
+ export declare function formatIssue(issue: RedmineIssue, lookup?: NameLookup): string;
6
7
  /**
7
8
  * Format an issue API response as Markdown
8
9
  */
9
10
  export declare function formatIssueResponse(response: {
10
11
  issue: RedmineIssue;
11
- }): string;
12
+ }, lookup?: NameLookup): string;
@@ -8,7 +8,7 @@ function formatDate(isoDate) {
8
8
  /**
9
9
  * Format an issue as complete Markdown
10
10
  */
11
- export function formatIssue(issue) {
11
+ export function formatIssue(issue, lookup = {}) {
12
12
  const lines = [];
13
13
  // Title
14
14
  lines.push(`# #${issue.id}: ${issue.subject}`);
@@ -116,13 +116,13 @@ export function formatIssue(issue) {
116
116
  }
117
117
  // Journals (history)
118
118
  if (issue.journals && issue.journals.length > 0) {
119
- lines.push(formatJournals(issue.journals));
119
+ lines.push(formatJournals(issue.journals, lookup));
120
120
  }
121
121
  return lines.join("\n");
122
122
  }
123
123
  /**
124
124
  * Format an issue API response as Markdown
125
125
  */
126
- export function formatIssueResponse(response) {
127
- return formatIssue(response.issue);
126
+ export function formatIssueResponse(response, lookup = {}) {
127
+ return formatIssue(response.issue, lookup);
128
128
  }
@@ -1,5 +1,10 @@
1
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>>;
2
7
  /**
3
8
  * Format an array of journal entries as Markdown
4
9
  */
5
- export declare function formatJournals(journals: RedmineJournal[]): string;
10
+ export declare function formatJournals(journals: RedmineJournal[], lookup?: NameLookup): string;
@@ -3,6 +3,71 @@ import { diffLines } from "diff";
3
3
  * Fields that contain large text and should use diff formatting
4
4
  */
5
5
  const LARGE_TEXT_FIELDS = ["description"];
6
+ /**
7
+ * Format checklist changes as a readable diff
8
+ * Only shows items that changed between old and new state
9
+ */
10
+ function formatChecklistDiff(oldValue, newValue) {
11
+ let oldItems = [];
12
+ let newItems = [];
13
+ try {
14
+ oldItems = JSON.parse(oldValue || "[]");
15
+ }
16
+ catch {
17
+ // Invalid JSON, fall back to raw display
18
+ return null;
19
+ }
20
+ try {
21
+ newItems = JSON.parse(newValue || "[]");
22
+ }
23
+ catch {
24
+ return null;
25
+ }
26
+ // Create maps for easy lookup by subject (more reliable than ID for diffing)
27
+ const oldBySubject = new Map(oldItems.map(item => [item.subject, item]));
28
+ const newBySubject = new Map(newItems.map(item => [item.subject, item]));
29
+ const changes = [];
30
+ // Find changed and added items
31
+ for (const newItem of newItems) {
32
+ const oldItem = oldBySubject.get(newItem.subject);
33
+ if (!oldItem) {
34
+ // New item added
35
+ const status = newItem.is_done ? "✅" : "❌";
36
+ changes.push(` - ➕ ${newItem.subject}: ${status}`);
37
+ }
38
+ else if (oldItem.is_done !== newItem.is_done) {
39
+ // Status changed
40
+ const oldStatus = oldItem.is_done ? "✅" : "❌";
41
+ const newStatus = newItem.is_done ? "✅" : "❌";
42
+ changes.push(` - ${newItem.subject}: ${oldStatus} → ${newStatus}`);
43
+ }
44
+ // If subject and status are same, no change to report
45
+ }
46
+ // Find removed items
47
+ for (const oldItem of oldItems) {
48
+ if (!newBySubject.has(oldItem.subject)) {
49
+ const status = oldItem.is_done ? "✅" : "❌";
50
+ changes.push(` - ➖ ${oldItem.subject}: ${status}`);
51
+ }
52
+ }
53
+ if (changes.length === 0) {
54
+ return null; // No meaningful changes
55
+ }
56
+ return changes.join("\n");
57
+ }
58
+ /**
59
+ * Field name mappings for display (remove _id suffix)
60
+ */
61
+ const FIELD_DISPLAY_NAMES = {
62
+ status_id: "status",
63
+ tracker_id: "tracker",
64
+ priority_id: "priority",
65
+ assigned_to_id: "assigned_to",
66
+ category_id: "category",
67
+ fixed_version_id: "version",
68
+ parent_id: "parent",
69
+ project_id: "project",
70
+ };
6
71
  /**
7
72
  * Format a date string to a readable format
8
73
  */
@@ -32,10 +97,26 @@ function generateDiff(oldValue, newValue) {
32
97
  }
33
98
  return lines.join("\n");
34
99
  }
100
+ /**
101
+ * Resolve an ID value to a display string with name
102
+ */
103
+ function resolveValue(fieldName, value, lookup) {
104
+ if (!value)
105
+ return "_(empty)_";
106
+ const fieldLookup = lookup[fieldName];
107
+ if (fieldLookup && fieldLookup[value]) {
108
+ return `${fieldLookup[value]} (${value})`;
109
+ }
110
+ // For parent_id, show as issue reference
111
+ if (fieldName === "parent_id") {
112
+ return `#${value}`;
113
+ }
114
+ return value;
115
+ }
35
116
  /**
36
117
  * Format a single journal detail (field change)
37
118
  */
38
- function formatDetail(detail) {
119
+ function formatDetail(detail, lookup) {
39
120
  const { property, name, old_value, new_value } = detail;
40
121
  // Handle attachment additions/removals
41
122
  if (property === "attachment") {
@@ -68,15 +149,31 @@ function formatDetail(detail) {
68
149
  }
69
150
  // Handle attribute changes
70
151
  if (property === "attr") {
152
+ // Checklist changes get special formatting
153
+ if (name === "checklist") {
154
+ const checklistDiff = formatChecklistDiff(old_value || "", new_value || "");
155
+ if (checklistDiff) {
156
+ return `- checklist:\n${checklistDiff}`;
157
+ }
158
+ // Fall through to default formatting if parsing failed
159
+ }
71
160
  // Large text fields get diff formatting
72
161
  if (LARGE_TEXT_FIELDS.includes(name) && old_value && new_value) {
73
162
  const diff = generateDiff(old_value, new_value);
74
163
  return `- ${name}:\n\`\`\`diff\n${diff}\n\`\`\``;
75
164
  }
165
+ // Get display name for the field
166
+ const displayName = FIELD_DISPLAY_NAMES[name] || name;
167
+ // Resolve ID fields to names
168
+ if (name.endsWith("_id") && lookup[name]) {
169
+ const oldDisplay = resolveValue(name, old_value, lookup);
170
+ const newDisplay = resolveValue(name, new_value, lookup);
171
+ return `- ${displayName}: ${oldDisplay} → ${newDisplay}`;
172
+ }
76
173
  // Simple field changes
77
174
  const oldDisplay = old_value || "_(empty)_";
78
175
  const newDisplay = new_value || "_(empty)_";
79
- return `- ${name}: ${oldDisplay} → ${newDisplay}`;
176
+ return `- ${displayName}: ${oldDisplay} → ${newDisplay}`;
80
177
  }
81
178
  // Fallback for unknown property types
82
179
  return `- ${property}.${name}: ${old_value || "_(empty)_"} → ${new_value || "_(empty)_"}`;
@@ -84,7 +181,7 @@ function formatDetail(detail) {
84
181
  /**
85
182
  * Format a single journal entry as Markdown
86
183
  */
87
- function formatJournalEntry(journal) {
184
+ function formatJournalEntry(journal, lookup) {
88
185
  const lines = [];
89
186
  // Header with date and user
90
187
  const date = formatDate(journal.created_on);
@@ -101,7 +198,7 @@ function formatJournalEntry(journal) {
101
198
  if (journal.details && journal.details.length > 0) {
102
199
  lines.push("**Changes:**");
103
200
  for (const detail of journal.details) {
104
- lines.push(formatDetail(detail));
201
+ lines.push(formatDetail(detail, lookup));
105
202
  }
106
203
  lines.push("");
107
204
  }
@@ -110,11 +207,11 @@ function formatJournalEntry(journal) {
110
207
  /**
111
208
  * Format an array of journal entries as Markdown
112
209
  */
113
- export function formatJournals(journals) {
210
+ export function formatJournals(journals, lookup = {}) {
114
211
  if (!journals || journals.length === 0) {
115
212
  return "";
116
213
  }
117
214
  const header = `## History (${journals.length} entries)\n\n`;
118
- const entries = journals.map(formatJournalEntry).join("\n---\n\n");
215
+ const entries = journals.map(j => formatJournalEntry(j, lookup)).join("\n---\n\n");
119
216
  return header + entries;
120
217
  }
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.3",
6
+ version: "0.4.5",
7
7
  });
8
8
  registerTools(server, redmineClient, toolGroups);
9
9
  return server;
@@ -35,16 +35,43 @@ export function registerCoreTools(server, client) {
35
35
  include: z.string().optional().describe("Include: attachments, relations, journals, watchers, children, changesets, allowed_statuses"),
36
36
  },
37
37
  }, async (params) => {
38
- const result = await client.getIssue(params.issue_id, params.include);
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
+ ]);
39
45
  // Check if this is an error response
40
46
  if ("error" in result) {
41
47
  return {
42
48
  content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
43
49
  };
44
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
+ }
45
72
  // Format response as Markdown
46
73
  return {
47
- content: [{ type: "text", text: formatIssueResponse(result) }],
74
+ content: [{ type: "text", text: formatIssueResponse(result, lookup) }],
48
75
  };
49
76
  });
50
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",
3
+ "version": "0.4.5",
4
4
  "description": "MCP server for Redmine - full API access with configurable tool groups",
5
5
  "type": "module",
6
6
  "bin": {