@pschroee/redmine-mcp 0.4.2 → 0.4.3

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 } 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,11 @@
1
+ import type { RedmineIssue } from "../redmine/types.js";
2
+ /**
3
+ * Format an issue as complete Markdown
4
+ */
5
+ export declare function formatIssue(issue: RedmineIssue): string;
6
+ /**
7
+ * Format an issue API response as Markdown
8
+ */
9
+ export declare function formatIssueResponse(response: {
10
+ issue: RedmineIssue;
11
+ }): 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) {
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));
120
+ }
121
+ return lines.join("\n");
122
+ }
123
+ /**
124
+ * Format an issue API response as Markdown
125
+ */
126
+ export function formatIssueResponse(response) {
127
+ return formatIssue(response.issue);
128
+ }
@@ -0,0 +1,5 @@
1
+ import type { RedmineJournal } from "../redmine/types.js";
2
+ /**
3
+ * Format an array of journal entries as Markdown
4
+ */
5
+ export declare function formatJournals(journals: RedmineJournal[]): string;
@@ -0,0 +1,120 @@
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
+ * Format a date string to a readable format
8
+ */
9
+ function formatDate(isoDate) {
10
+ const date = new Date(isoDate);
11
+ return date.toISOString().slice(0, 16).replace("T", " ");
12
+ }
13
+ /**
14
+ * Generate a unified diff for large text changes
15
+ */
16
+ function generateDiff(oldValue, newValue) {
17
+ const changes = diffLines(oldValue || "", newValue || "");
18
+ const lines = [];
19
+ for (const change of changes) {
20
+ const prefix = change.added ? "+" : change.removed ? "-" : " ";
21
+ const text = change.value.replace(/\n$/, "");
22
+ // Skip unchanged parts that are too long (context)
23
+ if (!change.added && !change.removed && text.length > 200) {
24
+ lines.push(" [...]");
25
+ continue;
26
+ }
27
+ for (const line of text.split("\n")) {
28
+ if (line || change.added || change.removed) {
29
+ lines.push(`${prefix} ${line}`);
30
+ }
31
+ }
32
+ }
33
+ return lines.join("\n");
34
+ }
35
+ /**
36
+ * Format a single journal detail (field change)
37
+ */
38
+ function formatDetail(detail) {
39
+ const { property, name, old_value, new_value } = detail;
40
+ // Handle attachment additions/removals
41
+ if (property === "attachment") {
42
+ if (new_value && !old_value) {
43
+ return `- Added attachment: ${new_value}`;
44
+ }
45
+ if (old_value && !new_value) {
46
+ return `- Removed attachment: ${old_value}`;
47
+ }
48
+ }
49
+ // Handle relation changes
50
+ if (property === "relation") {
51
+ if (new_value && !old_value) {
52
+ return `- Added relation: ${name} → #${new_value}`;
53
+ }
54
+ if (old_value && !new_value) {
55
+ return `- Removed relation: ${name} → #${old_value}`;
56
+ }
57
+ }
58
+ // Handle custom fields
59
+ if (property === "cf") {
60
+ const fieldName = name;
61
+ if (!old_value && new_value) {
62
+ return `- ${fieldName}: _(empty)_ → ${new_value}`;
63
+ }
64
+ if (old_value && !new_value) {
65
+ return `- ${fieldName}: ${old_value} → _(empty)_`;
66
+ }
67
+ return `- ${fieldName}: ${old_value} → ${new_value}`;
68
+ }
69
+ // Handle attribute changes
70
+ if (property === "attr") {
71
+ // Large text fields get diff formatting
72
+ if (LARGE_TEXT_FIELDS.includes(name) && old_value && new_value) {
73
+ const diff = generateDiff(old_value, new_value);
74
+ return `- ${name}:\n\`\`\`diff\n${diff}\n\`\`\``;
75
+ }
76
+ // Simple field changes
77
+ const oldDisplay = old_value || "_(empty)_";
78
+ const newDisplay = new_value || "_(empty)_";
79
+ return `- ${name}: ${oldDisplay} → ${newDisplay}`;
80
+ }
81
+ // Fallback for unknown property types
82
+ return `- ${property}.${name}: ${old_value || "_(empty)_"} → ${new_value || "_(empty)_"}`;
83
+ }
84
+ /**
85
+ * Format a single journal entry as Markdown
86
+ */
87
+ function formatJournalEntry(journal) {
88
+ const lines = [];
89
+ // Header with date and user
90
+ const date = formatDate(journal.created_on);
91
+ const user = journal.user.name;
92
+ const privateTag = journal.private_notes ? " 🔒" : "";
93
+ lines.push(`### ${date} - ${user}${privateTag}`);
94
+ lines.push("");
95
+ // Notes (if any)
96
+ if (journal.notes && journal.notes.trim()) {
97
+ lines.push(journal.notes.trim());
98
+ lines.push("");
99
+ }
100
+ // Details (field changes)
101
+ if (journal.details && journal.details.length > 0) {
102
+ lines.push("**Changes:**");
103
+ for (const detail of journal.details) {
104
+ lines.push(formatDetail(detail));
105
+ }
106
+ lines.push("");
107
+ }
108
+ return lines.join("\n");
109
+ }
110
+ /**
111
+ * Format an array of journal entries as Markdown
112
+ */
113
+ export function formatJournals(journals) {
114
+ if (!journals || journals.length === 0) {
115
+ return "";
116
+ }
117
+ const header = `## History (${journals.length} entries)\n\n`;
118
+ const entries = journals.map(formatJournalEntry).join("\n---\n\n");
119
+ return header + entries;
120
+ }
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.3",
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,22 @@ 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
  const result = await client.getIssue(params.issue_id, params.include);
39
+ // Check if this is an error response
40
+ if ("error" in result) {
41
+ return {
42
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
43
+ };
44
+ }
45
+ // Format response as Markdown
38
46
  return {
39
- content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
47
+ content: [{ type: "text", text: formatIssueResponse(result) }],
40
48
  };
41
49
  });
42
50
  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.3",
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",