@martin4455/redmine-mcp-ro 0.3.0

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,7 @@
1
+ import type { Tool } from '@modelcontextprotocol/sdk/types.js';
2
+ /** Validate against the same schemas advertised in tools/list, before any side effect. */
3
+ export declare class ToolArgumentValidator {
4
+ private readonly validators;
5
+ constructor(tools: readonly Tool[]);
6
+ validate(name: string, input: unknown): Record<string, unknown>;
7
+ }
@@ -0,0 +1,20 @@
1
+ import { AjvJsonSchemaValidator } from '@modelcontextprotocol/sdk/validation/ajv';
2
+ /** Validate against the same schemas advertised in tools/list, before any side effect. */
3
+ export class ToolArgumentValidator {
4
+ validators = new Map();
5
+ constructor(tools) {
6
+ const provider = new AjvJsonSchemaValidator();
7
+ for (const tool of tools) {
8
+ this.validators.set(tool.name, provider.getValidator(tool.inputSchema));
9
+ }
10
+ }
11
+ validate(name, input) {
12
+ const validate = this.validators.get(name);
13
+ if (!validate)
14
+ throw new Error(`Unknown tool: ${name}`);
15
+ const result = validate(input);
16
+ if (!result.valid)
17
+ throw new Error(`Invalid arguments for ${name}: ${result.errorMessage}`);
18
+ return result.data;
19
+ }
20
+ }
@@ -0,0 +1,247 @@
1
+ export interface RedmineConfig {
2
+ url: string;
3
+ apiKey: string;
4
+ requestTimeoutMs?: number;
5
+ maxAttachmentBytes?: number;
6
+ maxContentBytes?: number;
7
+ downloadDir?: string;
8
+ }
9
+ /** Persisted configuration contains references only; never URLs or API keys. */
10
+ export interface RedmineProfile {
11
+ credentialId: string;
12
+ }
13
+ export type LocalSettings = Pick<RedmineConfig, 'requestTimeoutMs' | 'maxAttachmentBytes' | 'maxContentBytes' | 'downloadDir'>;
14
+ export interface ConfigFile {
15
+ version: '3.0';
16
+ defaultProfile?: string;
17
+ profiles: Record<string, RedmineProfile>;
18
+ settings?: LocalSettings;
19
+ }
20
+ /** Read only by the explicit migration command. */
21
+ export interface LegacyConfigFile {
22
+ version: '1.0';
23
+ defaultProfile?: string;
24
+ profiles: Record<string, {
25
+ url: string;
26
+ apiKey: string;
27
+ }>;
28
+ settings?: LocalSettings;
29
+ }
30
+ /** Both development variants used 2.0: with a local URL, or references only. */
31
+ export interface LegacyReferenceConfigFile {
32
+ version: '2.0';
33
+ defaultProfile?: string;
34
+ profiles: Record<string, {
35
+ credentialId: string;
36
+ url?: string;
37
+ }>;
38
+ settings?: LocalSettings;
39
+ }
40
+ export type StoredConfigFile = ConfigFile | LegacyConfigFile | LegacyReferenceConfigFile;
41
+ export interface CredentialStore {
42
+ get(id: string): Promise<string | null>;
43
+ set(id: string, value: string): Promise<void>;
44
+ delete(id: string): Promise<void>;
45
+ }
46
+ export interface RedmineAttachment {
47
+ id: number;
48
+ filename: string;
49
+ content_url?: string;
50
+ href_url?: string;
51
+ content_type?: string;
52
+ }
53
+ export interface AttachmentIssue {
54
+ attachments?: RedmineAttachment[];
55
+ description?: string;
56
+ journals?: Array<{
57
+ notes?: string;
58
+ }>;
59
+ }
60
+ export interface ParsedAttachment {
61
+ id: number;
62
+ filename: string;
63
+ url: string;
64
+ timestamp?: number;
65
+ version?: boolean;
66
+ }
67
+ export interface AttachmentDownloadOptions {
68
+ version?: boolean;
69
+ timestamp?: number;
70
+ fallbackFilename?: string;
71
+ }
72
+ export interface DownloadedAttachment {
73
+ filepath: string;
74
+ filename: string;
75
+ size: number;
76
+ content_type?: string;
77
+ }
78
+ export interface DownloadedIssueAttachment extends DownloadedAttachment {
79
+ attachment_id: number;
80
+ source?: 'api' | 'embedded';
81
+ }
82
+ export interface AttachmentContent {
83
+ filename: string;
84
+ content: string;
85
+ content_type?: string;
86
+ size: number;
87
+ }
88
+ export interface RedmineIssue {
89
+ id: number;
90
+ project: {
91
+ id: number;
92
+ name: string;
93
+ };
94
+ tracker: {
95
+ id: number;
96
+ name: string;
97
+ };
98
+ status: {
99
+ id: number;
100
+ name: string;
101
+ is_closed?: boolean;
102
+ };
103
+ priority: {
104
+ id: number;
105
+ name: string;
106
+ };
107
+ author: {
108
+ id: number;
109
+ name: string;
110
+ };
111
+ assigned_to?: {
112
+ id: number;
113
+ name: string;
114
+ };
115
+ subject: string;
116
+ description?: string;
117
+ start_date?: string;
118
+ due_date?: string;
119
+ done_ratio?: number;
120
+ is_private?: boolean;
121
+ estimated_hours?: number;
122
+ spent_hours?: number;
123
+ created_on: string;
124
+ updated_on: string;
125
+ closed_on?: string;
126
+ easy_sprint?: {
127
+ id: number;
128
+ name?: string;
129
+ due_date?: string;
130
+ };
131
+ easy_sprint_phase?: number;
132
+ easy_sprint_position?: number;
133
+ easy_story_points?: number;
134
+ }
135
+ export interface RedmineProject {
136
+ id: number;
137
+ name: string;
138
+ identifier: string;
139
+ description?: string;
140
+ homepage?: string;
141
+ status: number;
142
+ is_public?: boolean;
143
+ inherit_members?: boolean;
144
+ created_on: string;
145
+ updated_on: string;
146
+ parent?: {
147
+ id: number;
148
+ name: string;
149
+ };
150
+ }
151
+ export interface RedmineUser {
152
+ id: number;
153
+ login: string;
154
+ admin?: boolean;
155
+ firstname: string;
156
+ lastname: string;
157
+ mail?: string;
158
+ created_on: string;
159
+ updated_on: string;
160
+ last_login_on?: string;
161
+ passwd_changed_on?: string;
162
+ status?: number;
163
+ }
164
+ export interface RedmineTimeEntry {
165
+ id: number;
166
+ project: {
167
+ id: number;
168
+ name: string;
169
+ };
170
+ issue?: {
171
+ id: number;
172
+ };
173
+ user: {
174
+ id: number;
175
+ name: string;
176
+ };
177
+ activity: {
178
+ id: number;
179
+ name: string;
180
+ };
181
+ hours: number;
182
+ comments?: string;
183
+ spent_on: string;
184
+ created_on: string;
185
+ updated_on: string;
186
+ }
187
+ export interface RedmineVersion {
188
+ id: number;
189
+ project: {
190
+ id: number;
191
+ name: string;
192
+ };
193
+ name: string;
194
+ description?: string;
195
+ status: string;
196
+ due_date?: string;
197
+ sharing: string;
198
+ wiki_page_title?: string;
199
+ estimated_hours?: number;
200
+ spent_hours?: number;
201
+ created_on: string;
202
+ updated_on: string;
203
+ }
204
+ export interface RedmineNews {
205
+ id: number;
206
+ project: {
207
+ id: number;
208
+ name: string;
209
+ };
210
+ author: {
211
+ id: number;
212
+ name: string;
213
+ };
214
+ title: string;
215
+ summary?: string;
216
+ description?: string;
217
+ created_on: string;
218
+ }
219
+ export interface RedmineWikiPage {
220
+ title: string;
221
+ parent?: {
222
+ title: string;
223
+ };
224
+ text?: string;
225
+ version: number;
226
+ author: {
227
+ id: number;
228
+ name: string;
229
+ };
230
+ comments?: string;
231
+ created_on: string;
232
+ updated_on: string;
233
+ }
234
+ export interface PaginationParams {
235
+ offset?: number;
236
+ limit?: number;
237
+ }
238
+ export interface IssueFilterParams extends PaginationParams {
239
+ project_id?: number | string;
240
+ subproject_id?: string;
241
+ tracker_id?: number;
242
+ status_id?: number | string;
243
+ assigned_to_id?: number | string;
244
+ parent_id?: number;
245
+ query_id?: number;
246
+ sort?: string;
247
+ }
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,24 @@
1
+ import { RedmineClient } from './redmine-client.js';
2
+ /**
3
+ * UserResolver helps resolve user names or emails to user IDs
4
+ */
5
+ export declare class UserResolver {
6
+ private userCache;
7
+ private client;
8
+ constructor(client: RedmineClient);
9
+ /**
10
+ * Resolves a user identifier (name, email, or ID) to a numeric user ID
11
+ * @param userIdentifier - Can be:
12
+ * - Numeric ID (returned as-is)
13
+ * - "me" or "current" (returns current user ID)
14
+ * - Email address
15
+ * - Full name (First Last)
16
+ * - Username
17
+ * @returns The numeric user ID or the original value if resolution fails
18
+ */
19
+ resolveUser(userIdentifier: string | number): Promise<string | number>;
20
+ /**
21
+ * Clear the user cache
22
+ */
23
+ clearCache(): void;
24
+ }
@@ -0,0 +1,140 @@
1
+ import { safeErrorMessage } from './safe-error.js';
2
+ /**
3
+ * UserResolver helps resolve user names or emails to user IDs
4
+ */
5
+ export class UserResolver {
6
+ userCache = new Map();
7
+ client;
8
+ constructor(client) {
9
+ this.client = client;
10
+ }
11
+ /**
12
+ * Resolves a user identifier (name, email, or ID) to a numeric user ID
13
+ * @param userIdentifier - Can be:
14
+ * - Numeric ID (returned as-is)
15
+ * - "me" or "current" (returns current user ID)
16
+ * - Email address
17
+ * - Full name (First Last)
18
+ * - Username
19
+ * @returns The numeric user ID or the original value if resolution fails
20
+ */
21
+ async resolveUser(userIdentifier) {
22
+ // If it's already a number, return it
23
+ if (typeof userIdentifier === 'number') {
24
+ return userIdentifier;
25
+ }
26
+ // Convert to string and trim
27
+ const identifier = String(userIdentifier).trim();
28
+ // If it's empty, return it as-is
29
+ if (!identifier) {
30
+ return identifier;
31
+ }
32
+ // Handle special values
33
+ if (identifier.toLowerCase() === 'me' || identifier.toLowerCase() === 'current') {
34
+ try {
35
+ const currentUser = await this.client.getCurrentUser();
36
+ return currentUser.id;
37
+ }
38
+ catch (error) {
39
+ console.error('Failed to get current user:', safeErrorMessage(error));
40
+ return identifier;
41
+ }
42
+ }
43
+ // If it's a numeric string, return it as number
44
+ if (/^\d+$/.test(identifier)) {
45
+ return parseInt(identifier, 10);
46
+ }
47
+ // Check cache first
48
+ if (this.userCache.has(identifier)) {
49
+ return this.userCache.get(identifier);
50
+ }
51
+ // Try to find user by name or email
52
+ try {
53
+ // First, try to get all users (requires admin privileges)
54
+ // If this fails, we'll fall back to other methods
55
+ const users = await this.client.getUsers({ limit: 100 });
56
+ for (const user of users.users) {
57
+ // Check by email (exact match)
58
+ if (user.mail && user.mail.toLowerCase() === identifier.toLowerCase()) {
59
+ this.userCache.set(identifier, user.id);
60
+ return user.id;
61
+ }
62
+ // Check by full name (case-insensitive)
63
+ const fullName = `${user.firstname} ${user.lastname}`.toLowerCase();
64
+ if (fullName === identifier.toLowerCase()) {
65
+ this.userCache.set(identifier, user.id);
66
+ return user.id;
67
+ }
68
+ // Check by login/username (case-insensitive)
69
+ if (user.login && user.login.toLowerCase() === identifier.toLowerCase()) {
70
+ this.userCache.set(identifier, user.id);
71
+ return user.id;
72
+ }
73
+ }
74
+ }
75
+ catch (error) {
76
+ // If we can't list users (not admin), try using time_entries API
77
+ console.error('Cannot list users (requires admin). Trying time_entries API...');
78
+ try {
79
+ // Get recent time entries to build a user mapping
80
+ // This doesn't require admin privileges!
81
+ const timeEntries = await this.client.getTimeEntries({ limit: 100 });
82
+ // Build a map of user names to IDs from time entries
83
+ for (const entry of timeEntries.time_entries) {
84
+ if (entry.user) {
85
+ const userName = entry.user.name;
86
+ const userId = entry.user.id;
87
+ // Store in cache for future use
88
+ if (userName && !this.userCache.has(userName)) {
89
+ this.userCache.set(userName, userId);
90
+ }
91
+ // Check if this matches what we're looking for
92
+ if (userName && userName.toLowerCase() === identifier.toLowerCase()) {
93
+ return userId;
94
+ }
95
+ }
96
+ }
97
+ // Also try to get issues to find more users (authors, assignees)
98
+ try {
99
+ const issues = await this.client.getIssues({ limit: 100 });
100
+ for (const issue of issues.issues) {
101
+ // Check author
102
+ if (issue.author && issue.author.name) {
103
+ if (!this.userCache.has(issue.author.name)) {
104
+ this.userCache.set(issue.author.name, issue.author.id);
105
+ }
106
+ if (issue.author.name.toLowerCase() === identifier.toLowerCase()) {
107
+ return issue.author.id;
108
+ }
109
+ }
110
+ // Check assigned_to
111
+ if (issue.assigned_to && issue.assigned_to.name) {
112
+ if (!this.userCache.has(issue.assigned_to.name)) {
113
+ this.userCache.set(issue.assigned_to.name, issue.assigned_to.id);
114
+ }
115
+ if (issue.assigned_to.name.toLowerCase() === identifier.toLowerCase()) {
116
+ return issue.assigned_to.id;
117
+ }
118
+ }
119
+ }
120
+ }
121
+ catch (issuesError) {
122
+ console.error('Could not fetch issues for user resolution:', safeErrorMessage(issuesError));
123
+ }
124
+ }
125
+ catch (timeEntriesError) {
126
+ console.error('Time entries lookup also failed:', safeErrorMessage(timeEntriesError));
127
+ }
128
+ }
129
+ // If we couldn't resolve it, return the original identifier
130
+ // This will likely cause an API error, but at least we tried
131
+ console.error('Could not resolve user identifier.');
132
+ return identifier;
133
+ }
134
+ /**
135
+ * Clear the user cache
136
+ */
137
+ clearCache() {
138
+ this.userCache.clear();
139
+ }
140
+ }
package/package.json ADDED
@@ -0,0 +1,78 @@
1
+ {
2
+ "name": "@martin4455/redmine-mcp-ro",
3
+ "version": "0.3.0",
4
+ "description": "Model Context Protocol server for read-only access to Redmine REST API",
5
+ "main": "dist/index.js",
6
+ "type": "module",
7
+ "scripts": {
8
+ "build": "node scripts/build.mjs",
9
+ "dev": "tsx src/index.ts",
10
+ "start": "node dist/index.js",
11
+ "test": "vitest run",
12
+ "test:integration": "npm run build && node test/run-automated-test.js",
13
+ "lint": "eslint src",
14
+ "typecheck": "tsc --noEmit",
15
+ "check:package": "node scripts/check-package.mjs",
16
+ "prepack": "npm run build && npm run check:package",
17
+ "configure": "node dist/cli.js configure",
18
+ "config:status": "node dist/cli.js status",
19
+ "config:list": "node dist/cli.js list",
20
+ "config:migrate": "node dist/cli.js migrate",
21
+ "test:keyring": "npm run build && node scripts/test-keyring.mjs",
22
+ "test:keyring:ubuntu": "npm run build && docker build -t redmine-mcp-keyring-test -f test/keyring/Dockerfile . && docker run --rm redmine-mcp-keyring-test"
23
+ },
24
+ "bin": {
25
+ "redmine-mcp-ro": "dist/index.js",
26
+ "redmine-mcp": "dist/cli.js"
27
+ },
28
+ "files": [
29
+ "dist/*.js",
30
+ "dist/*.d.ts",
31
+ "README.md",
32
+ "CONFIG.md",
33
+ "LICENSE"
34
+ ],
35
+ "keywords": [
36
+ "mcp",
37
+ "redmine",
38
+ "api",
39
+ "claude",
40
+ "ai"
41
+ ],
42
+ "author": "martin4455",
43
+ "license": "MIT",
44
+ "publishConfig": {
45
+ "access": "public",
46
+ "registry": "https://registry.npmjs.org/"
47
+ },
48
+ "dependencies": {
49
+ "@modelcontextprotocol/sdk": "1.31.0",
50
+ "@napi-rs/keyring": "2.1.0",
51
+ "axios": "1.20.0",
52
+ "chalk": "6.0.1",
53
+ "commander": "15.0.0",
54
+ "inquirer": "14.2.2",
55
+ "jsdom": "30.1.1"
56
+ },
57
+ "devDependencies": {
58
+ "@types/jsdom": "^30.0.0",
59
+ "@types/node": "^24.19.0",
60
+ "@typescript-eslint/eslint-plugin": "^8.71.0",
61
+ "@typescript-eslint/parser": "^8.71.0",
62
+ "eslint": "^10.11.0",
63
+ "tsx": "^4.23.15",
64
+ "typescript": "~6.0.3",
65
+ "vitest": "^5.0.3"
66
+ },
67
+ "engines": {
68
+ "node": "^24.15.0 || >=26.0.0"
69
+ },
70
+ "repository": {
71
+ "type": "git",
72
+ "url": "git+https://github.com/luskan/redmine-mcp.git"
73
+ },
74
+ "bugs": {
75
+ "url": "https://github.com/luskan/redmine-mcp/issues"
76
+ },
77
+ "homepage": "https://github.com/luskan/redmine-mcp#readme"
78
+ }