@blackenedd18/planio-connector 2026.623.2
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/.env.example +9 -0
- package/LICENSE +15 -0
- package/README.md +101 -0
- package/Redmine-functions/README.md +14 -0
- package/Redmine-functions/index.js +648 -0
- package/Redmine-functions/package.json +5 -0
- package/package.json +48 -0
- package/src/index.js +16 -0
- package/src/modules/hours/index.js +195 -0
- package/src/modules/issues/index.js +281 -0
- package/src/modules/projects/index.js +62 -0
- package/src/modules/time-entries/index.js +267 -0
- package/src/modules/users/index.js +87 -0
- package/src/server.js +87 -0
- package/src/shared/config.js +84 -0
- package/src/shared/date-validation.js +45 -0
- package/src/shared/logger.js +48 -0
- package/src/shared/redmine-functions-adapter.js +58 -0
- package/src/shared/redmine-functions-contract.js +4 -0
- package/src/shared/redmine-functions-entry.js +3 -0
- package/src/shared/request.js +97 -0
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
import { ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js';
|
|
2
|
+
import { getRuntimeConfig } from '../../shared/config.js';
|
|
3
|
+
import { logDebug, logError, logInfo, logWarn } from '../../shared/logger.js';
|
|
4
|
+
import { makeRequest } from '../../shared/request.js';
|
|
5
|
+
import { ensureOptionalIsoDate, resolveDateAlias } from '../../shared/date-validation.js';
|
|
6
|
+
|
|
7
|
+
export const TOOLS = [
|
|
8
|
+
{
|
|
9
|
+
name: 'get_time_entry_activities',
|
|
10
|
+
description: 'Get available time entry activities.',
|
|
11
|
+
inputSchema: {
|
|
12
|
+
type: 'object',
|
|
13
|
+
properties: {},
|
|
14
|
+
additionalProperties: false,
|
|
15
|
+
},
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
name: 'get_all_time_entries',
|
|
19
|
+
description: 'Return all time entries with optional filters.',
|
|
20
|
+
inputSchema: {
|
|
21
|
+
type: 'object',
|
|
22
|
+
properties: {
|
|
23
|
+
project_id: { type: 'number' },
|
|
24
|
+
issue_id: { type: 'number' },
|
|
25
|
+
user_id: { type: 'number' },
|
|
26
|
+
from: { type: 'string', description: 'YYYY-MM-DD' },
|
|
27
|
+
to: { type: 'string', description: 'YYYY-MM-DD' },
|
|
28
|
+
from_date: { type: 'string' },
|
|
29
|
+
to_date: { type: 'string' },
|
|
30
|
+
limit: { type: 'number' },
|
|
31
|
+
},
|
|
32
|
+
additionalProperties: false,
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
name: 'get_time_entry_by_id',
|
|
37
|
+
description: 'Return a single time entry by id.',
|
|
38
|
+
inputSchema: {
|
|
39
|
+
type: 'object',
|
|
40
|
+
properties: {
|
|
41
|
+
time_entry_id: { type: 'number' },
|
|
42
|
+
},
|
|
43
|
+
required: ['time_entry_id'],
|
|
44
|
+
additionalProperties: false,
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
name: 'update_time_entry',
|
|
49
|
+
description: 'Update a time entry by id.',
|
|
50
|
+
inputSchema: {
|
|
51
|
+
type: 'object',
|
|
52
|
+
properties: {
|
|
53
|
+
time_entry_id: { type: 'number' },
|
|
54
|
+
issue_id: { type: 'number' },
|
|
55
|
+
hours: { type: 'number' },
|
|
56
|
+
comments: { type: 'string' },
|
|
57
|
+
spent_on: { type: 'string', description: 'YYYY-MM-DD' },
|
|
58
|
+
activity_name: {
|
|
59
|
+
type: 'string',
|
|
60
|
+
enum: ['design', 'development', 'management', 'support', 'testing'],
|
|
61
|
+
description: 'Time entry activity. Get from get_time_entry_activities.',
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
required: ['time_entry_id'],
|
|
65
|
+
additionalProperties: false,
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
name: 'delete_time_entry',
|
|
70
|
+
description: 'Delete a time entry by id.',
|
|
71
|
+
inputSchema: {
|
|
72
|
+
type: 'object',
|
|
73
|
+
properties: {
|
|
74
|
+
time_entry_id: { type: 'number' },
|
|
75
|
+
},
|
|
76
|
+
required: ['time_entry_id'],
|
|
77
|
+
additionalProperties: false,
|
|
78
|
+
},
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
name: 'create_time_entry',
|
|
82
|
+
description:
|
|
83
|
+
'Create a new time entry. Requires issue_id, hours and activity_name. project_id is derived from the issue, user_id defaults to the authenticated user, and spent_on defaults to today when omitted.',
|
|
84
|
+
inputSchema: {
|
|
85
|
+
type: 'object',
|
|
86
|
+
properties: {
|
|
87
|
+
issue_id: { type: 'number' },
|
|
88
|
+
hours: { type: 'number' },
|
|
89
|
+
activity_name: {
|
|
90
|
+
type: 'string',
|
|
91
|
+
enum: ['design', 'development', 'management', 'support', 'testing'],
|
|
92
|
+
description: 'Time entry activity. Get from get_time_entry_activities.',
|
|
93
|
+
},
|
|
94
|
+
project_id: { type: 'number', description: 'Optional. Defaults to the issue project.' },
|
|
95
|
+
user_id: { type: 'number', description: 'Optional. Defaults to the authenticated user.' },
|
|
96
|
+
spent_on: { type: 'string', description: 'Optional. YYYY-MM-DD. Defaults to today.' },
|
|
97
|
+
comments: { type: 'string' },
|
|
98
|
+
},
|
|
99
|
+
required: ['issue_id', 'hours', 'activity_name'],
|
|
100
|
+
additionalProperties: false,
|
|
101
|
+
},
|
|
102
|
+
},
|
|
103
|
+
{
|
|
104
|
+
name: 'health',
|
|
105
|
+
description: 'Health check of server configuration.',
|
|
106
|
+
inputSchema: {
|
|
107
|
+
type: 'object',
|
|
108
|
+
properties: {},
|
|
109
|
+
additionalProperties: false,
|
|
110
|
+
},
|
|
111
|
+
},
|
|
112
|
+
];
|
|
113
|
+
|
|
114
|
+
function logToolStart(name, args) {
|
|
115
|
+
logDebug('[timeEntries.dispatch] Handling tool', {
|
|
116
|
+
toolName: name,
|
|
117
|
+
argumentKeys: Object.keys(args || {}),
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function ensureRequiredNumber(args, fieldName) {
|
|
122
|
+
if (typeof args[fieldName] !== 'number') {
|
|
123
|
+
throw new McpError(ErrorCode.InvalidParams, `Tool requires numeric field "${fieldName}".`);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function ensureRequiredString(args, fieldName) {
|
|
128
|
+
if (typeof args[fieldName] !== 'string' || !args[fieldName].trim()) {
|
|
129
|
+
throw new McpError(
|
|
130
|
+
ErrorCode.InvalidParams,
|
|
131
|
+
`Tool requires non-empty string field "${fieldName}".`
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function buildTimeEntryFilters(args) {
|
|
137
|
+
const params = {};
|
|
138
|
+
const fields = ['project_id', 'issue_id', 'user_id', 'limit'];
|
|
139
|
+
|
|
140
|
+
for (const field of fields) {
|
|
141
|
+
if (args[field] !== undefined && args[field] !== null) {
|
|
142
|
+
params[field] = args[field];
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const fromDate = resolveDateAlias(args, 'from', 'from_date');
|
|
147
|
+
const toDate = resolveDateAlias(args, 'to', 'to_date');
|
|
148
|
+
|
|
149
|
+
if (fromDate.value !== undefined && fromDate.value !== null) {
|
|
150
|
+
ensureOptionalIsoDate({ from: fromDate.value }, 'from');
|
|
151
|
+
params.from = fromDate.value;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (toDate.value !== undefined && toDate.value !== null) {
|
|
155
|
+
ensureOptionalIsoDate({ to: toDate.value }, 'to');
|
|
156
|
+
params.to = toDate.value;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (fromDate.conflict || toDate.conflict) {
|
|
160
|
+
logWarn('[timeEntries.dispatch] Conflicting canonical and legacy date aliases', {
|
|
161
|
+
fromConflict: fromDate.conflict,
|
|
162
|
+
toConflict: toDate.conflict,
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
logDebug('[timeEntries.dispatch] Date aliases mapped', {
|
|
167
|
+
fromSource: fromDate.source,
|
|
168
|
+
toSource: toDate.source,
|
|
169
|
+
hadLegacyFromDate: fromDate.hadLegacy,
|
|
170
|
+
hadLegacyToDate: toDate.hadLegacy,
|
|
171
|
+
mappedKeys: Object.keys(params),
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
return params;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function buildUpdatePayload(args) {
|
|
178
|
+
const payload = {};
|
|
179
|
+
const fields = ['issue_id', 'hours', 'comments', 'spent_on', 'activity_name'];
|
|
180
|
+
|
|
181
|
+
for (const field of fields) {
|
|
182
|
+
if (args[field] !== undefined && args[field] !== null) {
|
|
183
|
+
payload[field] = args[field];
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return payload;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export async function dispatch(name, args = {}) {
|
|
191
|
+
try {
|
|
192
|
+
switch (name) {
|
|
193
|
+
case 'get_time_entry_activities':
|
|
194
|
+
logToolStart(name, args);
|
|
195
|
+
return makeRequest('GET', 'time_entries/activities');
|
|
196
|
+
case 'get_all_time_entries':
|
|
197
|
+
logToolStart(name, args);
|
|
198
|
+
return makeRequest('GET', 'time_entries/list', buildTimeEntryFilters(args));
|
|
199
|
+
case 'get_time_entry_by_id':
|
|
200
|
+
logToolStart(name, args);
|
|
201
|
+
ensureRequiredNumber(args, 'time_entry_id');
|
|
202
|
+
return makeRequest('GET', `time_entries/${args.time_entry_id}`);
|
|
203
|
+
case 'update_time_entry': {
|
|
204
|
+
logToolStart(name, args);
|
|
205
|
+
ensureRequiredNumber(args, 'time_entry_id');
|
|
206
|
+
ensureOptionalIsoDate(args, 'spent_on');
|
|
207
|
+
const payload = buildUpdatePayload(args);
|
|
208
|
+
return makeRequest('PUT', `time_entries/${args.time_entry_id}`, undefined, payload);
|
|
209
|
+
}
|
|
210
|
+
case 'delete_time_entry':
|
|
211
|
+
logToolStart(name, args);
|
|
212
|
+
ensureRequiredNumber(args, 'time_entry_id');
|
|
213
|
+
return makeRequest('DELETE', `time_entries/${args.time_entry_id}`);
|
|
214
|
+
case 'create_time_entry': {
|
|
215
|
+
logToolStart(name, args);
|
|
216
|
+
ensureRequiredNumber(args, 'issue_id');
|
|
217
|
+
ensureRequiredNumber(args, 'hours');
|
|
218
|
+
ensureRequiredString(args, 'activity_name');
|
|
219
|
+
ensureOptionalIsoDate(args, 'spent_on');
|
|
220
|
+
|
|
221
|
+
const payload = {
|
|
222
|
+
issue_id: args.issue_id,
|
|
223
|
+
hours: args.hours,
|
|
224
|
+
activity_name: args.activity_name,
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
for (const field of ['project_id', 'user_id', 'spent_on', 'comments']) {
|
|
228
|
+
if (args[field] !== undefined && args[field] !== null) {
|
|
229
|
+
payload[field] = args[field];
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
return makeRequest('POST', 'time_entries', undefined, payload);
|
|
234
|
+
}
|
|
235
|
+
case 'health': {
|
|
236
|
+
logToolStart(name, args);
|
|
237
|
+
getRuntimeConfig();
|
|
238
|
+
logInfo('[timeEntries.dispatch] Health check passed');
|
|
239
|
+
const payload = { status: 'ok' };
|
|
240
|
+
return {
|
|
241
|
+
content: [{ type: 'text', text: JSON.stringify(payload) }],
|
|
242
|
+
structuredContent: payload,
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
default:
|
|
246
|
+
return null;
|
|
247
|
+
}
|
|
248
|
+
} catch (error) {
|
|
249
|
+
logError('[timeEntries.dispatch] Tool execution failed', {
|
|
250
|
+
toolName: name,
|
|
251
|
+
message: error?.message,
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
if (name === 'health') {
|
|
255
|
+
const payload = {
|
|
256
|
+
status: 'error',
|
|
257
|
+
message: error?.message || 'Health check failed',
|
|
258
|
+
};
|
|
259
|
+
return {
|
|
260
|
+
content: [{ type: 'text', text: JSON.stringify(payload) }],
|
|
261
|
+
structuredContent: payload,
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
throw error;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js';
|
|
2
|
+
import { logDebug, logError } from '../../shared/logger.js';
|
|
3
|
+
import { makeRequest } from '../../shared/request.js';
|
|
4
|
+
|
|
5
|
+
export const TOOLS = [
|
|
6
|
+
{
|
|
7
|
+
name: 'get_current_date_api',
|
|
8
|
+
description:
|
|
9
|
+
'Returns the current date (YYYY-MM-DD), the current day name, and the current week number.',
|
|
10
|
+
inputSchema: {
|
|
11
|
+
type: 'object',
|
|
12
|
+
properties: {},
|
|
13
|
+
additionalProperties: false,
|
|
14
|
+
},
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
name: 'get_my_user_id',
|
|
18
|
+
description: 'Returns the unique identifier of the authenticated user.',
|
|
19
|
+
inputSchema: {
|
|
20
|
+
type: 'object',
|
|
21
|
+
properties: {},
|
|
22
|
+
additionalProperties: false,
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
name: 'get_my_user_info',
|
|
27
|
+
description: 'Returns detailed information about the authenticated user.',
|
|
28
|
+
inputSchema: {
|
|
29
|
+
type: 'object',
|
|
30
|
+
properties: {},
|
|
31
|
+
additionalProperties: false,
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
name: 'get_all_users_ids',
|
|
36
|
+
description: 'Retrieves a complete list of all users in the system.',
|
|
37
|
+
inputSchema: {
|
|
38
|
+
type: 'object',
|
|
39
|
+
properties: {},
|
|
40
|
+
additionalProperties: false,
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
];
|
|
44
|
+
|
|
45
|
+
function logToolStart(name, args) {
|
|
46
|
+
logDebug('[users.dispatch] Handling tool', {
|
|
47
|
+
toolName: name,
|
|
48
|
+
argumentKeys: Object.keys(args || {}),
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function ensureNoArgs(args) {
|
|
53
|
+
if (Object.keys(args || {}).length > 0) {
|
|
54
|
+
throw new McpError(ErrorCode.InvalidParams, 'This tool does not accept any arguments.');
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export async function dispatch(name, args = {}) {
|
|
59
|
+
try {
|
|
60
|
+
switch (name) {
|
|
61
|
+
case 'get_current_date_api':
|
|
62
|
+
logToolStart(name, args);
|
|
63
|
+
ensureNoArgs(args);
|
|
64
|
+
return makeRequest('GET', 'data/current-date');
|
|
65
|
+
case 'get_my_user_id':
|
|
66
|
+
logToolStart(name, args);
|
|
67
|
+
ensureNoArgs(args);
|
|
68
|
+
return makeRequest('GET', 'users/my-id');
|
|
69
|
+
case 'get_my_user_info':
|
|
70
|
+
logToolStart(name, args);
|
|
71
|
+
ensureNoArgs(args);
|
|
72
|
+
return makeRequest('GET', 'users/my');
|
|
73
|
+
case 'get_all_users_ids':
|
|
74
|
+
logToolStart(name, args);
|
|
75
|
+
ensureNoArgs(args);
|
|
76
|
+
return makeRequest('GET', 'users/all');
|
|
77
|
+
default:
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
} catch (error) {
|
|
81
|
+
logError('[users.dispatch] Tool execution failed', {
|
|
82
|
+
toolName: name,
|
|
83
|
+
message: error?.message,
|
|
84
|
+
});
|
|
85
|
+
throw error;
|
|
86
|
+
}
|
|
87
|
+
}
|
package/src/server.js
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
2
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
3
|
+
import {
|
|
4
|
+
CallToolRequestSchema,
|
|
5
|
+
ErrorCode,
|
|
6
|
+
ListToolsRequestSchema,
|
|
7
|
+
McpError,
|
|
8
|
+
} from '@modelcontextprotocol/sdk/types.js';
|
|
9
|
+
import { createRequire } from 'node:module';
|
|
10
|
+
import { logError, logInfo } from './shared/logger.js';
|
|
11
|
+
import { validateRuntimeConfig } from './shared/config.js';
|
|
12
|
+
import * as users from './modules/users/index.js';
|
|
13
|
+
import * as issues from './modules/issues/index.js';
|
|
14
|
+
import * as projects from './modules/projects/index.js';
|
|
15
|
+
import * as hours from './modules/hours/index.js';
|
|
16
|
+
import * as timeEntries from './modules/time-entries/index.js';
|
|
17
|
+
|
|
18
|
+
const { version: SERVER_VERSION } = createRequire(import.meta.url)('../package.json');
|
|
19
|
+
const SERVER_NAME = 'planio-connector-mcp';
|
|
20
|
+
const MODULES = [users, issues, projects, hours, timeEntries];
|
|
21
|
+
|
|
22
|
+
export class RedmineServer {
|
|
23
|
+
constructor() {
|
|
24
|
+
this.modules = MODULES;
|
|
25
|
+
this.server = new Server(
|
|
26
|
+
{
|
|
27
|
+
name: SERVER_NAME,
|
|
28
|
+
version: SERVER_VERSION,
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
capabilities: {
|
|
32
|
+
tools: {},
|
|
33
|
+
},
|
|
34
|
+
}
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
this.setupHandlers();
|
|
38
|
+
this.setupErrorHandling();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
setupHandlers() {
|
|
42
|
+
this.server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
43
|
+
tools: this.modules.flatMap((moduleRef) => moduleRef.TOOLS),
|
|
44
|
+
}));
|
|
45
|
+
|
|
46
|
+
this.server.setRequestHandler(CallToolRequestSchema, async ({ params }) => {
|
|
47
|
+
const { name, arguments: args } = params;
|
|
48
|
+
|
|
49
|
+
for (const moduleRef of this.modules) {
|
|
50
|
+
const result = await moduleRef.dispatch(name, args ?? {});
|
|
51
|
+
if (result !== null) {
|
|
52
|
+
return result;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${name}`);
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
setupErrorHandling() {
|
|
61
|
+
this.server.onerror = (error) => {
|
|
62
|
+
logError('[MCP Error] Server error', {
|
|
63
|
+
message: error?.message,
|
|
64
|
+
stack: error?.stack,
|
|
65
|
+
});
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
process.on('SIGINT', async () => {
|
|
69
|
+
try {
|
|
70
|
+
await this.server.close();
|
|
71
|
+
} catch (error) {
|
|
72
|
+
logError('[MCP Error] Error during shutdown', {
|
|
73
|
+
message: error?.message,
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
process.exit(0);
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async run() {
|
|
81
|
+
validateRuntimeConfig();
|
|
82
|
+
const transport = new StdioServerTransport();
|
|
83
|
+
logInfo('[server.run] Connecting MCP stdio transport');
|
|
84
|
+
await this.server.connect(transport);
|
|
85
|
+
logInfo('[server.run] MCP server connected');
|
|
86
|
+
}
|
|
87
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js';
|
|
2
|
+
import { logDebug, logError, logInfo } from './logger.js';
|
|
3
|
+
|
|
4
|
+
const VALID_LOG_LEVELS = new Set(['debug', 'info', 'warn', 'error']);
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Returns the value of a CLI flag (--flag value) from process.argv, or null if absent.
|
|
8
|
+
*/
|
|
9
|
+
function getArgValue(flag) {
|
|
10
|
+
const idx = process.argv.indexOf(flag);
|
|
11
|
+
if (idx !== -1 && process.argv[idx + 1] && !process.argv[idx + 1].startsWith('-')) {
|
|
12
|
+
return process.argv[idx + 1];
|
|
13
|
+
}
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function requireApiKey() {
|
|
18
|
+
const fromArg = getArgValue('--api-key');
|
|
19
|
+
if (fromArg && fromArg.trim()) {
|
|
20
|
+
logDebug('[FIX] REDMINE_API_KEY resolved from --api-key CLI argument');
|
|
21
|
+
return fromArg.trim();
|
|
22
|
+
}
|
|
23
|
+
const fromEnv = process.env.REDMINE_API_KEY;
|
|
24
|
+
if (fromEnv && fromEnv.trim()) {
|
|
25
|
+
return fromEnv.trim();
|
|
26
|
+
}
|
|
27
|
+
throw new McpError(
|
|
28
|
+
ErrorCode.InvalidRequest,
|
|
29
|
+
'Missing API key. Provide it via --api-key <key> argument or REDMINE_API_KEY env var.'
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function requireRedmineUrl() {
|
|
34
|
+
const fromArg = getArgValue('--redmine-url');
|
|
35
|
+
if (fromArg && fromArg.trim()) {
|
|
36
|
+
logDebug('[config] REDMINE_URL resolved from --redmine-url CLI argument');
|
|
37
|
+
return fromArg.trim().replace(/\/$/, '');
|
|
38
|
+
}
|
|
39
|
+
const fromEnv = process.env.REDMINE_URL;
|
|
40
|
+
if (fromEnv && fromEnv.trim()) {
|
|
41
|
+
return fromEnv.trim().replace(/\/$/, '');
|
|
42
|
+
}
|
|
43
|
+
throw new McpError(
|
|
44
|
+
ErrorCode.InvalidRequest,
|
|
45
|
+
'Missing Redmine URL. Provide it via --redmine-url <url> argument or REDMINE_URL env var.'
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function getLogLevel() {
|
|
50
|
+
const raw = (process.env.LOG_LEVEL || 'debug').toLowerCase();
|
|
51
|
+
if (!VALID_LOG_LEVELS.has(raw)) {
|
|
52
|
+
throw new McpError(
|
|
53
|
+
ErrorCode.InvalidRequest,
|
|
54
|
+
'Invalid LOG_LEVEL. Expected one of: debug, info, warn, error.'
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
return raw;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function getRuntimeConfig() {
|
|
61
|
+
return {
|
|
62
|
+
redmineApiKey: requireApiKey(),
|
|
63
|
+
redmineUrl: requireRedmineUrl(),
|
|
64
|
+
logLevel: getLogLevel(),
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function validateRuntimeConfig() {
|
|
69
|
+
try {
|
|
70
|
+
const config = getRuntimeConfig();
|
|
71
|
+
logDebug('[config.validateRuntimeConfig] Runtime config valid', {
|
|
72
|
+
hasApiKey: Boolean(config.redmineApiKey),
|
|
73
|
+
hasRedmineUrl: Boolean(config.redmineUrl),
|
|
74
|
+
logLevel: config.logLevel,
|
|
75
|
+
});
|
|
76
|
+
logInfo('[config.validateRuntimeConfig] Runtime configuration loaded');
|
|
77
|
+
return config;
|
|
78
|
+
} catch (error) {
|
|
79
|
+
logError('[config.validateRuntimeConfig] Runtime config invalid', {
|
|
80
|
+
message: error?.message,
|
|
81
|
+
});
|
|
82
|
+
throw error;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js';
|
|
2
|
+
|
|
3
|
+
const ISO_DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
|
|
4
|
+
|
|
5
|
+
export function isIsoDate(value) {
|
|
6
|
+
return typeof value === 'string' && ISO_DATE_PATTERN.test(value);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function ensureOptionalIsoDate(args, fieldName) {
|
|
10
|
+
const value = args?.[fieldName];
|
|
11
|
+
if (value === undefined || value === null) {
|
|
12
|
+
return;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
if (!isIsoDate(value)) {
|
|
16
|
+
throw new McpError(
|
|
17
|
+
ErrorCode.InvalidParams,
|
|
18
|
+
`Field "${fieldName}" must be a date string in YYYY-MM-DD format.`
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function resolveDateAlias(args, canonicalName, legacyName) {
|
|
24
|
+
const canonicalValue = args?.[canonicalName];
|
|
25
|
+
const legacyValue = args?.[legacyName];
|
|
26
|
+
|
|
27
|
+
if (canonicalValue !== undefined && canonicalValue !== null) {
|
|
28
|
+
return {
|
|
29
|
+
value: canonicalValue,
|
|
30
|
+
source: canonicalName,
|
|
31
|
+
hadLegacy: legacyValue !== undefined && legacyValue !== null,
|
|
32
|
+
conflict:
|
|
33
|
+
legacyValue !== undefined &&
|
|
34
|
+
legacyValue !== null &&
|
|
35
|
+
String(legacyValue) !== String(canonicalValue),
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return {
|
|
40
|
+
value: legacyValue,
|
|
41
|
+
source: legacyValue !== undefined && legacyValue !== null ? legacyName : null,
|
|
42
|
+
hadLegacy: legacyValue !== undefined && legacyValue !== null,
|
|
43
|
+
conflict: false,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
const LOG_LEVEL = (process.env.LOG_LEVEL || 'debug').toLowerCase();
|
|
2
|
+
|
|
3
|
+
const LEVELS = {
|
|
4
|
+
debug: 10,
|
|
5
|
+
info: 20,
|
|
6
|
+
warn: 30,
|
|
7
|
+
error: 40,
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
function shouldLog(level) {
|
|
11
|
+
return (LEVELS[level] || LEVELS.info) >= (LEVELS[LOG_LEVEL] || LEVELS.debug);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function sanitizeMeta(meta) {
|
|
15
|
+
if (!meta || typeof meta !== 'object') {
|
|
16
|
+
return meta;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const clone = { ...meta };
|
|
20
|
+
if (clone.apiKey) {
|
|
21
|
+
clone.apiKey = '***';
|
|
22
|
+
}
|
|
23
|
+
if (clone.headers && clone.headers['X-Redmine-API-Key']) {
|
|
24
|
+
clone.headers = { ...clone.headers, 'X-Redmine-API-Key': '***' };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return clone;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function logDebug(message, meta = {}) {
|
|
31
|
+
if (!shouldLog('debug')) return;
|
|
32
|
+
console.error('[DEBUG]', message, sanitizeMeta(meta));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function logInfo(message, meta = {}) {
|
|
36
|
+
if (!shouldLog('info')) return;
|
|
37
|
+
console.error('[INFO]', message, sanitizeMeta(meta));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function logWarn(message, meta = {}) {
|
|
41
|
+
if (!shouldLog('warn')) return;
|
|
42
|
+
console.error('[WARN]', message, sanitizeMeta(meta));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function logError(message, meta = {}) {
|
|
46
|
+
if (!shouldLog('error')) return;
|
|
47
|
+
console.error('[ERROR]', message, sanitizeMeta(meta));
|
|
48
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js';
|
|
2
|
+
import { executeRequest, listHandlers } from './redmine-functions-entry.js';
|
|
3
|
+
import { REDMINE_FUNCTIONS_CONTRACT } from './redmine-functions-contract.js';
|
|
4
|
+
import { logDebug, logError } from './logger.js';
|
|
5
|
+
|
|
6
|
+
let handlersLogged = false;
|
|
7
|
+
|
|
8
|
+
function ensureContract() {
|
|
9
|
+
const missing = REDMINE_FUNCTIONS_CONTRACT.requiredExports.filter((name) => {
|
|
10
|
+
if (name === 'executeRequest') return typeof executeRequest !== 'function';
|
|
11
|
+
if (name === 'listHandlers') return typeof listHandlers !== 'function';
|
|
12
|
+
return false;
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
if (missing.length > 0) {
|
|
16
|
+
logError('[redmineFunctionsAdapter.ensureContract] Missing required exports', {
|
|
17
|
+
missing,
|
|
18
|
+
});
|
|
19
|
+
throw new McpError(
|
|
20
|
+
ErrorCode.InternalError,
|
|
21
|
+
`Redmine-functions contract mismatch. Missing exports: ${missing.join(', ')}`
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function callRedmineFunctionAdapter({ method, endpoint, params, body, apiKey, redmineUrl }) {
|
|
27
|
+
ensureContract();
|
|
28
|
+
|
|
29
|
+
if (!handlersLogged) {
|
|
30
|
+
logDebug('[FIX] [redmineFunctionsAdapter.call] Using in-package Redmine-functions source', {
|
|
31
|
+
source: 'Redmine-functions/index.js',
|
|
32
|
+
});
|
|
33
|
+
logDebug('[redmineFunctionsAdapter.call] Adapter initialized', {
|
|
34
|
+
contract: REDMINE_FUNCTIONS_CONTRACT.executeSignature,
|
|
35
|
+
handlers: listHandlers(),
|
|
36
|
+
});
|
|
37
|
+
handlersLogged = true;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
logDebug('[redmineFunctionsAdapter.call] Mapping request to local function', {
|
|
41
|
+
method,
|
|
42
|
+
endpoint,
|
|
43
|
+
paramKeys: params ? Object.keys(params) : [],
|
|
44
|
+
noNetworkMode: true,
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
try {
|
|
48
|
+
return await executeRequest({ method, endpoint, params, body, apiKey, redmineUrl });
|
|
49
|
+
} catch (error) {
|
|
50
|
+
logError('[redmineFunctionsAdapter.call] Local function execution failed', {
|
|
51
|
+
method,
|
|
52
|
+
endpoint,
|
|
53
|
+
status: error?.status,
|
|
54
|
+
message: error?.message,
|
|
55
|
+
});
|
|
56
|
+
throw error;
|
|
57
|
+
}
|
|
58
|
+
}
|