@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.
@@ -0,0 +1,2 @@
1
+ export { formatJournals, type NameLookup } from "./journals.js";
2
+ export { formatIssue, formatIssueResponse } from "./issue.js";
@@ -0,0 +1,2 @@
1
+ export { formatJournals } from "./journals.js";
2
+ export { formatIssue, formatIssueResponse } from "./issue.js";
@@ -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.2",
6
+ version: "0.4.4",
7
7
  });
8
8
  registerTools(server, redmineClient, toolGroups);
9
9
  return server;
@@ -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
- 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
+ ]);
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: JSON.stringify(result, null, 2) }],
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.2",
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",