@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.
- package/CONFIG.md +242 -0
- package/LICENSE +21 -0
- package/README.md +196 -0
- package/dist/api-key-filter.d.ts +6 -0
- package/dist/api-key-filter.js +16 -0
- package/dist/attachment-downloader.d.ts +15 -0
- package/dist/attachment-downloader.js +190 -0
- package/dist/attachment-parser.d.ts +27 -0
- package/dist/attachment-parser.js +138 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +144 -0
- package/dist/config-file.d.ts +18 -0
- package/dist/config-file.js +192 -0
- package/dist/config-loader.d.ts +14 -0
- package/dist/config-loader.js +58 -0
- package/dist/credential-store.d.ts +17 -0
- package/dist/credential-store.js +71 -0
- package/dist/download-directory.d.ts +3 -0
- package/dist/download-directory.js +51 -0
- package/dist/http-client.d.ts +14 -0
- package/dist/http-client.js +99 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +25 -0
- package/dist/local-configuration.d.ts +35 -0
- package/dist/local-configuration.js +218 -0
- package/dist/redmine-client.d.ts +100 -0
- package/dist/redmine-client.js +184 -0
- package/dist/safe-error.d.ts +2 -0
- package/dist/safe-error.js +18 -0
- package/dist/server.d.ts +2 -0
- package/dist/server.js +222 -0
- package/dist/tool-definitions.d.ts +2 -0
- package/dist/tool-definitions.js +460 -0
- package/dist/tool-validation.d.ts +7 -0
- package/dist/tool-validation.js +20 -0
- package/dist/types.d.ts +247 -0
- package/dist/types.js +1 -0
- package/dist/user-resolver.d.ts +24 -0
- package/dist/user-resolver.js +140 -0
- package/package.json +78 -0
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
4
|
+
import { positiveInteger, redmineBaseUrl, requestLimits } from './http-client.js';
|
|
5
|
+
import { validateDownloadDir } from './download-directory.js';
|
|
6
|
+
export const CONFIG_FILENAME = '.redmine-mcp';
|
|
7
|
+
export const CREDENTIAL_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
|
8
|
+
export class ConfigurationError extends Error {
|
|
9
|
+
}
|
|
10
|
+
export function normalizeRedmineUrl(value) {
|
|
11
|
+
return redmineBaseUrl(value).href.replace(/\/+$/, '');
|
|
12
|
+
}
|
|
13
|
+
export function validateProfileName(value) {
|
|
14
|
+
if (!/^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$/.test(value) || ['constructor', 'prototype'].includes(value)) {
|
|
15
|
+
throw new ConfigurationError('Profile names must contain 1-64 letters, numbers, dots, underscores or hyphens, starting with a letter or number.');
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
function record(value) {
|
|
19
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
20
|
+
}
|
|
21
|
+
function keysAre(value, allowed) {
|
|
22
|
+
return Object.keys(value).every(key => allowed.includes(key));
|
|
23
|
+
}
|
|
24
|
+
export function parseConfig(raw) {
|
|
25
|
+
// Parser/validation diagnostics must never quote input: it may contain a legacy token.
|
|
26
|
+
try {
|
|
27
|
+
const input = JSON.parse(raw);
|
|
28
|
+
if (!record(input) || !keysAre(input, ['version', 'defaultProfile', 'profiles', 'settings']) ||
|
|
29
|
+
typeof input.version !== 'string' || !['1.0', '2.0', '3.0'].includes(input.version) || !record(input.profiles))
|
|
30
|
+
throw new Error();
|
|
31
|
+
const profiles = Object.create(null);
|
|
32
|
+
const legacyProfiles = Object.create(null);
|
|
33
|
+
const references = Object.create(null);
|
|
34
|
+
let settings;
|
|
35
|
+
if (input.settings !== undefined) {
|
|
36
|
+
if (!record(input.settings) || !keysAre(input.settings, ['requestTimeoutMs', 'maxAttachmentBytes', 'maxContentBytes', 'downloadDir']))
|
|
37
|
+
throw new Error();
|
|
38
|
+
for (const [name, value] of Object.entries(input.settings)) {
|
|
39
|
+
if (name === 'downloadDir') {
|
|
40
|
+
if (typeof value !== 'string')
|
|
41
|
+
throw new Error();
|
|
42
|
+
validateDownloadDir(value);
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
if (typeof value !== 'number')
|
|
46
|
+
throw new Error();
|
|
47
|
+
positiveInteger(value, name);
|
|
48
|
+
}
|
|
49
|
+
settings = { ...input.settings };
|
|
50
|
+
requestLimits({ url: '', apiKey: '', ...settings });
|
|
51
|
+
}
|
|
52
|
+
const ids = new Set();
|
|
53
|
+
for (const [index, [name, profile]] of Object.entries(input.profiles).entries()) {
|
|
54
|
+
if (input.version === '3.0')
|
|
55
|
+
validateProfileName(name);
|
|
56
|
+
else if (!name)
|
|
57
|
+
throw new Error();
|
|
58
|
+
const legacyUrl = (value) => {
|
|
59
|
+
try {
|
|
60
|
+
return normalizeRedmineUrl(value);
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
throw new ConfigurationError(`Legacy profile #${index + 1}: URL must be absolute HTTP(S), without credentials, query or fragment. Configuration was not changed.`);
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
if (!record(profile))
|
|
67
|
+
throw new Error();
|
|
68
|
+
if (input.version === '3.0' || input.version === '2.0') {
|
|
69
|
+
const allowed = input.version === '3.0' ? ['credentialId'] : ['url', 'credentialId'];
|
|
70
|
+
if (!keysAre(profile, allowed) || typeof profile.credentialId !== 'string' ||
|
|
71
|
+
!CREDENTIAL_ID.test(profile.credentialId) || ids.has(profile.credentialId))
|
|
72
|
+
throw new Error();
|
|
73
|
+
ids.add(profile.credentialId);
|
|
74
|
+
if (input.version === '3.0')
|
|
75
|
+
profiles[name] = { credentialId: profile.credentialId };
|
|
76
|
+
else {
|
|
77
|
+
if (profile.url !== undefined && typeof profile.url !== 'string')
|
|
78
|
+
throw new Error();
|
|
79
|
+
references[name] = { credentialId: profile.credentialId,
|
|
80
|
+
...(profile.url !== undefined ? { url: legacyUrl(profile.url) } : {}) };
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
else {
|
|
84
|
+
if (!keysAre(profile, ['url', 'apiKey', 'name', 'isDefault']) ||
|
|
85
|
+
typeof profile.url !== 'string' || typeof profile.apiKey !== 'string' ||
|
|
86
|
+
!profile.apiKey.trim() || /[\r\n\0]/.test(profile.apiKey))
|
|
87
|
+
throw new Error();
|
|
88
|
+
legacyProfiles[name] = { url: legacyUrl(profile.url), apiKey: profile.apiKey.trim() };
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
const defaultProfile = input.defaultProfile;
|
|
92
|
+
const selectedProfiles = input.version === '3.0' ? profiles : input.version === '2.0' ? references : legacyProfiles;
|
|
93
|
+
if (defaultProfile !== undefined && (typeof defaultProfile !== 'string' || !Object.hasOwn(selectedProfiles, defaultProfile)))
|
|
94
|
+
throw new Error();
|
|
95
|
+
const common = { defaultProfile: defaultProfile, ...(settings ? { settings } : {}) };
|
|
96
|
+
if (input.version === '3.0')
|
|
97
|
+
return { version: '3.0', ...common, profiles };
|
|
98
|
+
if (input.version === '2.0')
|
|
99
|
+
return { version: '2.0', ...common, profiles: references };
|
|
100
|
+
return { version: '1.0', ...common, profiles: legacyProfiles };
|
|
101
|
+
}
|
|
102
|
+
catch (error) {
|
|
103
|
+
if (error instanceof ConfigurationError)
|
|
104
|
+
throw error;
|
|
105
|
+
throw new ConfigurationError('Invalid configuration. Expected version 3.0 with credentialId-only profiles, or a valid version 1.0/2.0 file for migration.');
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
export function readConfig(configPath) {
|
|
109
|
+
let descriptor;
|
|
110
|
+
try {
|
|
111
|
+
let info;
|
|
112
|
+
try {
|
|
113
|
+
info = fs.lstatSync(configPath);
|
|
114
|
+
}
|
|
115
|
+
catch (error) {
|
|
116
|
+
if (error.code === 'ENOENT')
|
|
117
|
+
return { raw: null, config: null };
|
|
118
|
+
throw error;
|
|
119
|
+
}
|
|
120
|
+
if (!info.isFile() || info.isSymbolicLink() || info.size > 1024 * 1024)
|
|
121
|
+
throw new Error();
|
|
122
|
+
descriptor = fs.openSync(configPath, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0));
|
|
123
|
+
const opened = fs.fstatSync(descriptor);
|
|
124
|
+
if (!opened.isFile() || opened.size > 1024 * 1024 || opened.ino !== info.ino || opened.dev !== info.dev)
|
|
125
|
+
throw new Error();
|
|
126
|
+
const raw = fs.readFileSync(descriptor, 'utf8');
|
|
127
|
+
return { raw, config: parseConfig(raw) };
|
|
128
|
+
}
|
|
129
|
+
catch (error) {
|
|
130
|
+
if (error instanceof ConfigurationError)
|
|
131
|
+
throw error;
|
|
132
|
+
throw new ConfigurationError('Cannot read configuration. Use a regular file of at most 1 MiB; symlinks are not accepted.');
|
|
133
|
+
}
|
|
134
|
+
finally {
|
|
135
|
+
if (descriptor !== undefined)
|
|
136
|
+
fs.closeSync(descriptor);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
export function requireModern(config) {
|
|
140
|
+
if (config && config.version !== '3.0') {
|
|
141
|
+
throw new ConfigurationError('This file uses a legacy configuration format. Run redmine-mcp migrate --config-dir <directory> first.');
|
|
142
|
+
}
|
|
143
|
+
return config || { version: '3.0', profiles: Object.create(null) };
|
|
144
|
+
}
|
|
145
|
+
/** Atomic replacement; temporary files contain validated, secret-free metadata only. */
|
|
146
|
+
export function writeConfig(configPath, config, expectedRaw) {
|
|
147
|
+
const validated = parseConfig(JSON.stringify(config));
|
|
148
|
+
if (validated.version !== '3.0')
|
|
149
|
+
throw new ConfigurationError('Refusing to write connection data to the configuration file.');
|
|
150
|
+
const temporary = `${configPath}.tmp-${randomUUID()}`;
|
|
151
|
+
let descriptor;
|
|
152
|
+
try {
|
|
153
|
+
descriptor = fs.openSync(temporary, 'wx', 0o600);
|
|
154
|
+
fs.writeFileSync(descriptor, `${JSON.stringify(validated, null, 2)}\n`);
|
|
155
|
+
fs.fsyncSync(descriptor);
|
|
156
|
+
fs.closeSync(descriptor);
|
|
157
|
+
descriptor = undefined;
|
|
158
|
+
if (readConfig(configPath).raw !== expectedRaw) {
|
|
159
|
+
throw new ConfigurationError('Configuration changed during the operation. Retry using the current file.');
|
|
160
|
+
}
|
|
161
|
+
fs.renameSync(temporary, configPath);
|
|
162
|
+
}
|
|
163
|
+
catch (error) {
|
|
164
|
+
if (error instanceof ConfigurationError)
|
|
165
|
+
throw error;
|
|
166
|
+
throw new ConfigurationError('Cannot save configuration. Check directory permissions and retry.');
|
|
167
|
+
}
|
|
168
|
+
finally {
|
|
169
|
+
if (descriptor !== undefined)
|
|
170
|
+
fs.closeSync(descriptor);
|
|
171
|
+
fs.rmSync(temporary, { force: true });
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
export async function withConfigLock(configPath, action) {
|
|
175
|
+
let descriptor;
|
|
176
|
+
try {
|
|
177
|
+
descriptor = fs.openSync(`${configPath}.lock`, 'wx', 0o600);
|
|
178
|
+
}
|
|
179
|
+
catch {
|
|
180
|
+
throw new ConfigurationError('Cannot lock configuration. Check directory permissions; if no configurator is running, remove the stale .redmine-mcp.lock file.');
|
|
181
|
+
}
|
|
182
|
+
try {
|
|
183
|
+
return await action();
|
|
184
|
+
}
|
|
185
|
+
finally {
|
|
186
|
+
fs.closeSync(descriptor);
|
|
187
|
+
fs.unlinkSync(`${configPath}.lock`);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
export function localConfigPath(directory) {
|
|
191
|
+
return path.join(path.resolve(directory ?? process.env.REDMINE_CONFIG_DIR ?? process.cwd()), CONFIG_FILENAME);
|
|
192
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { type ConfigurationOptions } from './local-configuration.js';
|
|
2
|
+
import type { RedmineConfig } from './types.js';
|
|
3
|
+
export interface EffectiveConfig extends RedmineConfig {
|
|
4
|
+
source: string;
|
|
5
|
+
profile: string;
|
|
6
|
+
}
|
|
7
|
+
export declare class ConfigLoader {
|
|
8
|
+
private readonly options;
|
|
9
|
+
private readonly local;
|
|
10
|
+
constructor(options?: ConfigurationOptions);
|
|
11
|
+
/** Exactly one local file; no home-directory, .env or environment-credential fallback. */
|
|
12
|
+
getConfig(): Promise<EffectiveConfig | null>;
|
|
13
|
+
private optionalLimit;
|
|
14
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { ConfigurationError } from './config-file.js';
|
|
2
|
+
import { LocalConfiguration, readConnection } from './local-configuration.js';
|
|
3
|
+
import { requestLimits } from './http-client.js';
|
|
4
|
+
import { validateDownloadDir } from './download-directory.js';
|
|
5
|
+
export class ConfigLoader {
|
|
6
|
+
options;
|
|
7
|
+
local;
|
|
8
|
+
constructor(options = {}) {
|
|
9
|
+
this.options = options;
|
|
10
|
+
this.local = new LocalConfiguration(options);
|
|
11
|
+
}
|
|
12
|
+
/** Exactly one local file; no home-directory, .env or environment-credential fallback. */
|
|
13
|
+
async getConfig() {
|
|
14
|
+
const limits = {
|
|
15
|
+
requestTimeoutMs: this.optionalLimit('REDMINE_REQUEST_TIMEOUT_MS'),
|
|
16
|
+
maxAttachmentBytes: this.optionalLimit('REDMINE_MAX_ATTACHMENT_BYTES'),
|
|
17
|
+
maxContentBytes: this.optionalLimit('REDMINE_MAX_CONTENT_BYTES'),
|
|
18
|
+
};
|
|
19
|
+
try {
|
|
20
|
+
requestLimits({ url: '', apiKey: '', ...limits });
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
throw new ConfigurationError('Request limits must be positive safe integers; REDMINE_REQUEST_TIMEOUT_MS must not exceed 2147483647.');
|
|
24
|
+
}
|
|
25
|
+
const config = this.local.read();
|
|
26
|
+
if (!config)
|
|
27
|
+
return null;
|
|
28
|
+
const name = this.options.profile ?? process.env.REDMINE_PROFILE ?? config.defaultProfile ?? Object.keys(config.profiles)[0];
|
|
29
|
+
if (!name || !Object.hasOwn(config.profiles, name)) {
|
|
30
|
+
throw new ConfigurationError('Selected profile was not found in this local configuration.');
|
|
31
|
+
}
|
|
32
|
+
const profile = config.profiles[name];
|
|
33
|
+
const downloadDir = process.env.REDMINE_DOWNLOAD_DIR ?? config.settings?.downloadDir;
|
|
34
|
+
if (downloadDir !== undefined) {
|
|
35
|
+
try {
|
|
36
|
+
validateDownloadDir(downloadDir);
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
throw new ConfigurationError('REDMINE_DOWNLOAD_DIR or settings.downloadDir must be an absolute path to a dedicated download directory.');
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
const connection = await readConnection(this.local.store, profile);
|
|
43
|
+
const selectedLimits = {
|
|
44
|
+
requestTimeoutMs: limits.requestTimeoutMs ?? config.settings?.requestTimeoutMs,
|
|
45
|
+
maxAttachmentBytes: limits.maxAttachmentBytes ?? config.settings?.maxAttachmentBytes,
|
|
46
|
+
maxContentBytes: limits.maxContentBytes ?? config.settings?.maxContentBytes,
|
|
47
|
+
};
|
|
48
|
+
return { ...selectedLimits, downloadDir, ...connection, source: this.local.configPath, profile: name };
|
|
49
|
+
}
|
|
50
|
+
optionalLimit(name) {
|
|
51
|
+
const value = process.env[name];
|
|
52
|
+
if (value === undefined)
|
|
53
|
+
return undefined;
|
|
54
|
+
if (!/^\d+$/.test(value))
|
|
55
|
+
throw new ConfigurationError(`${name} must be a positive integer`);
|
|
56
|
+
return Number(value);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { ConfigurationError } from './config-file.js';
|
|
2
|
+
import type { CredentialStore } from './types.js';
|
|
3
|
+
export declare const KEYRING_SERVICE = "@martin4455/redmine-mcp-ro";
|
|
4
|
+
export declare const LEGACY_KEYRING_SERVICE = "redmine-mcp-ro";
|
|
5
|
+
export declare class CredentialStoreUnavailableError extends ConfigurationError {
|
|
6
|
+
}
|
|
7
|
+
export declare function validateCredentialSize(value: string, platform?: NodeJS.Platform): void;
|
|
8
|
+
/** Native APIs only: no shell arguments, files, keyutils or plaintext fallback. */
|
|
9
|
+
export declare class SystemCredentialStore implements CredentialStore {
|
|
10
|
+
private readonly namespace;
|
|
11
|
+
constructor(namespace?: 'current' | 'legacy');
|
|
12
|
+
private entry;
|
|
13
|
+
private unavailable;
|
|
14
|
+
get(id: string): Promise<string | null>;
|
|
15
|
+
set(id: string, value: string): Promise<void>;
|
|
16
|
+
delete(id: string): Promise<void>;
|
|
17
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { ConfigurationError, CREDENTIAL_ID } from './config-file.js';
|
|
2
|
+
export const KEYRING_SERVICE = '@martin4455/redmine-mcp-ro';
|
|
3
|
+
export const LEGACY_KEYRING_SERVICE = 'redmine-mcp-ro';
|
|
4
|
+
export class CredentialStoreUnavailableError extends ConfigurationError {
|
|
5
|
+
}
|
|
6
|
+
export function validateCredentialSize(value, platform = process.platform) {
|
|
7
|
+
if (!value)
|
|
8
|
+
throw new ConfigurationError('A nonempty credential value is required.');
|
|
9
|
+
if (platform === 'win32' && Buffer.byteLength(value, 'utf16le') > 2560) {
|
|
10
|
+
throw new ConfigurationError('Connection JSON exceeds the Windows credential store limit of 2560 UTF-16 bytes.');
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
/** Native APIs only: no shell arguments, files, keyutils or plaintext fallback. */
|
|
14
|
+
export class SystemCredentialStore {
|
|
15
|
+
namespace;
|
|
16
|
+
constructor(namespace = 'current') {
|
|
17
|
+
this.namespace = namespace;
|
|
18
|
+
}
|
|
19
|
+
async entry(id) {
|
|
20
|
+
if (!CREDENTIAL_ID.test(id))
|
|
21
|
+
throw new ConfigurationError('Invalid credential identifier.');
|
|
22
|
+
if (!['darwin', 'linux', 'win32'].includes(process.platform)) {
|
|
23
|
+
throw new ConfigurationError('System credential storage is supported on macOS, Linux and Windows.');
|
|
24
|
+
}
|
|
25
|
+
try {
|
|
26
|
+
const { AsyncEntry } = await import('@napi-rs/keyring');
|
|
27
|
+
const service = this.namespace === 'legacy' ? LEGACY_KEYRING_SERVICE : KEYRING_SERVICE;
|
|
28
|
+
return new AsyncEntry(service, id, { linux: { store: 'secret-service' } });
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
throw this.unavailable();
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
unavailable() {
|
|
35
|
+
const hint = process.platform === 'linux'
|
|
36
|
+
? 'Start and unlock a Secret Service provider (such as GNOME Keyring) in the same user D-Bus session.'
|
|
37
|
+
: process.platform === 'darwin'
|
|
38
|
+
? 'Unlock the login Keychain and allow access for this Node.js installation.'
|
|
39
|
+
: 'Check Windows Credential Manager availability for the account running MCP.';
|
|
40
|
+
return new CredentialStoreUnavailableError(`System credential store is unavailable or access was denied. ${hint} Native platform dependencies must be installed. No plaintext fallback is used.`);
|
|
41
|
+
}
|
|
42
|
+
async get(id) {
|
|
43
|
+
const entry = await this.entry(id);
|
|
44
|
+
try {
|
|
45
|
+
return (await entry.getPassword(AbortSignal.timeout(30_000))) ?? null;
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
// Native errors may contain secret data. Never propagate them to logs.
|
|
49
|
+
throw this.unavailable();
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
async set(id, value) {
|
|
53
|
+
validateCredentialSize(value);
|
|
54
|
+
const entry = await this.entry(id);
|
|
55
|
+
try {
|
|
56
|
+
await entry.setPassword(value, AbortSignal.timeout(30_000));
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
throw this.unavailable();
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
async delete(id) {
|
|
63
|
+
const entry = await this.entry(id);
|
|
64
|
+
try {
|
|
65
|
+
await entry.deleteCredential(AbortSignal.timeout(30_000));
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
throw this.unavailable();
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
export declare function validateDownloadDir(value: string): string;
|
|
2
|
+
/** The operator chooses the root; tool callers can only choose relative subdirectories. */
|
|
3
|
+
export declare function downloadDirectory(root: string | undefined, outputDir?: string, create?: boolean): Promise<string>;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { lstat, mkdir, realpath } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
export function validateDownloadDir(value) {
|
|
4
|
+
if (typeof value !== 'string' || !path.isAbsolute(value) || /[\x00-\x1f\x7f]/.test(value)) {
|
|
5
|
+
throw new Error('downloadDir must be an absolute path to a dedicated download directory');
|
|
6
|
+
}
|
|
7
|
+
return value;
|
|
8
|
+
}
|
|
9
|
+
/** The operator chooses the root; tool callers can only choose relative subdirectories. */
|
|
10
|
+
export async function downloadDirectory(root, outputDir, create = false) {
|
|
11
|
+
if (root === undefined) {
|
|
12
|
+
throw new Error('Local downloads are disabled. Configure REDMINE_DOWNLOAD_DIR or settings.downloadDir first.');
|
|
13
|
+
}
|
|
14
|
+
validateDownloadDir(root);
|
|
15
|
+
const relative = outputDir ?? '';
|
|
16
|
+
if (typeof relative !== 'string' || path.isAbsolute(relative) || path.win32.isAbsolute(relative) ||
|
|
17
|
+
/[\\:\x00-\x1f\x7f]/.test(relative) || relative.split('/').some(part => part === '..' || part.startsWith('~'))) {
|
|
18
|
+
throw new Error('output_dir must be a relative subdirectory of the configured download directory');
|
|
19
|
+
}
|
|
20
|
+
// The configured root must already exist. Its canonical location is trusted by the operator.
|
|
21
|
+
const canonicalRoot = await realpath(root);
|
|
22
|
+
if (!(await lstat(canonicalRoot)).isDirectory())
|
|
23
|
+
throw new Error('Configured download directory is not a directory');
|
|
24
|
+
let directory = canonicalRoot;
|
|
25
|
+
for (const component of relative.split('/').filter(part => part && part !== '.')) {
|
|
26
|
+
directory = path.join(directory, component);
|
|
27
|
+
let info;
|
|
28
|
+
try {
|
|
29
|
+
info = await lstat(directory);
|
|
30
|
+
}
|
|
31
|
+
catch (error) {
|
|
32
|
+
if (error.code !== 'ENOENT')
|
|
33
|
+
throw error;
|
|
34
|
+
if (!create)
|
|
35
|
+
continue;
|
|
36
|
+
try {
|
|
37
|
+
await mkdir(directory, { mode: 0o700 });
|
|
38
|
+
}
|
|
39
|
+
catch (error) {
|
|
40
|
+
if (error.code !== 'EEXIST')
|
|
41
|
+
throw error;
|
|
42
|
+
}
|
|
43
|
+
info = await lstat(directory);
|
|
44
|
+
}
|
|
45
|
+
if (info.isSymbolicLink() || !info.isDirectory())
|
|
46
|
+
throw new Error('Download subdirectories must be directories without symlinks');
|
|
47
|
+
if (await realpath(directory) !== directory)
|
|
48
|
+
throw new Error('Download directory changed during the operation');
|
|
49
|
+
}
|
|
50
|
+
return directory;
|
|
51
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { type AxiosInstance } from 'axios';
|
|
2
|
+
import type { RedmineConfig } from './types.js';
|
|
3
|
+
export declare const DEFAULT_REQUEST_TIMEOUT_MS = 30000;
|
|
4
|
+
export declare const DEFAULT_MAX_ATTACHMENT_BYTES: number;
|
|
5
|
+
export declare const DEFAULT_MAX_CONTENT_BYTES: number;
|
|
6
|
+
export declare function positiveInteger(value: number, name: string): number;
|
|
7
|
+
export declare function requestLimits(config: RedmineConfig): {
|
|
8
|
+
requestTimeoutMs: number;
|
|
9
|
+
maxAttachmentBytes: number;
|
|
10
|
+
maxContentBytes: number;
|
|
11
|
+
};
|
|
12
|
+
export declare function redmineBaseUrl(value: string): URL;
|
|
13
|
+
/** Apply the same origin and deadline policy to API calls, attachments and CLI checks. */
|
|
14
|
+
export declare function createRedmineHttpClient(config: RedmineConfig): AxiosInstance;
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import axios, { AxiosError } from 'axios';
|
|
2
|
+
export const DEFAULT_REQUEST_TIMEOUT_MS = 30_000;
|
|
3
|
+
export const DEFAULT_MAX_ATTACHMENT_BYTES = 50 * 1024 * 1024;
|
|
4
|
+
export const DEFAULT_MAX_CONTENT_BYTES = 5 * 1024 * 1024;
|
|
5
|
+
const MAX_API_RESPONSE_BYTES = 10 * 1024 * 1024;
|
|
6
|
+
export function positiveInteger(value, name) {
|
|
7
|
+
if (!Number.isSafeInteger(value) || value <= 0) {
|
|
8
|
+
throw new Error(`${name} must be a positive safe integer`);
|
|
9
|
+
}
|
|
10
|
+
return value;
|
|
11
|
+
}
|
|
12
|
+
export function requestLimits(config) {
|
|
13
|
+
const requestTimeoutMs = positiveInteger(config.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS, 'requestTimeoutMs');
|
|
14
|
+
if (requestTimeoutMs > 2_147_483_647) {
|
|
15
|
+
throw new Error('requestTimeoutMs must not exceed 2147483647');
|
|
16
|
+
}
|
|
17
|
+
return {
|
|
18
|
+
requestTimeoutMs,
|
|
19
|
+
maxAttachmentBytes: positiveInteger(config.maxAttachmentBytes ?? DEFAULT_MAX_ATTACHMENT_BYTES, 'maxAttachmentBytes'),
|
|
20
|
+
maxContentBytes: positiveInteger(config.maxContentBytes ?? DEFAULT_MAX_CONTENT_BYTES, 'maxContentBytes'),
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
export function redmineBaseUrl(value) {
|
|
24
|
+
let url;
|
|
25
|
+
try {
|
|
26
|
+
url = new URL(value);
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
throw new Error('Redmine URL must be an absolute HTTP or HTTPS URL');
|
|
30
|
+
}
|
|
31
|
+
if (!['https:', 'http:'].includes(url.protocol) || url.username || url.password || url.search || url.hash) {
|
|
32
|
+
throw new Error('Redmine URL must use HTTP or HTTPS without credentials, a query or a fragment');
|
|
33
|
+
}
|
|
34
|
+
return url;
|
|
35
|
+
}
|
|
36
|
+
/** Apply the same origin and deadline policy to API calls, attachments and CLI checks. */
|
|
37
|
+
export function createRedmineHttpClient(config) {
|
|
38
|
+
const base = redmineBaseUrl(config.url);
|
|
39
|
+
const { requestTimeoutMs } = requestLimits(config);
|
|
40
|
+
const assertOrigin = (url) => {
|
|
41
|
+
if (url.origin !== base.origin || url.username || url.password || !['http:', 'https:'].includes(url.protocol)) {
|
|
42
|
+
throw new Error('Blocked request outside the configured Redmine origin');
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
const nativeAdapter = axios.getAdapter('http');
|
|
46
|
+
const adapter = async (request) => {
|
|
47
|
+
let url = new URL(axios.getUri(request));
|
|
48
|
+
for (let redirects = 0;; redirects++) {
|
|
49
|
+
assertOrigin(url);
|
|
50
|
+
// maxRedirects: 0 uses Node's native transport. follow-redirects logs headers
|
|
51
|
+
// (including the API key) when DEBUG=* or DEBUG=follow-redirects is enabled.
|
|
52
|
+
// A single signal from the interceptor bounds the entire redirect chain.
|
|
53
|
+
const response = await nativeAdapter({
|
|
54
|
+
...request, url: url.href, baseURL: undefined, params: undefined,
|
|
55
|
+
maxRedirects: 0, validateStatus: () => true,
|
|
56
|
+
});
|
|
57
|
+
if (request.responseType === 'stream') {
|
|
58
|
+
// Axios can wrap an unread body in a lazy size-limiting stream. Closing
|
|
59
|
+
// that wrapper alone does not necessarily close the underlying socket.
|
|
60
|
+
response.data.once('close', () => response.request?.destroy?.());
|
|
61
|
+
}
|
|
62
|
+
const location = response.headers.location;
|
|
63
|
+
if ([301, 302, 303, 307, 308].includes(response.status) && location) {
|
|
64
|
+
response.data?.destroy?.();
|
|
65
|
+
if (redirects >= 5) {
|
|
66
|
+
throw new AxiosError('Maximum number of redirects exceeded', AxiosError.ERR_FR_TOO_MANY_REDIRECTS, request);
|
|
67
|
+
}
|
|
68
|
+
url = new URL(String(location), url);
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
if (request.validateStatus && !request.validateStatus(response.status)) {
|
|
72
|
+
response.data?.destroy?.();
|
|
73
|
+
throw new AxiosError(`Request failed with status code ${response.status}`, response.status >= 500 ? AxiosError.ERR_BAD_RESPONSE : AxiosError.ERR_BAD_REQUEST, request, response.request, response);
|
|
74
|
+
}
|
|
75
|
+
return response;
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
const client = axios.create({
|
|
79
|
+
baseURL: base.href.replace(/\/+$/, ''),
|
|
80
|
+
timeout: requestTimeoutMs,
|
|
81
|
+
maxContentLength: MAX_API_RESPONSE_BYTES,
|
|
82
|
+
headers: { 'X-Redmine-API-Key': config.apiKey },
|
|
83
|
+
adapter,
|
|
84
|
+
maxRedirects: 0,
|
|
85
|
+
});
|
|
86
|
+
client.interceptors.request.use((request) => {
|
|
87
|
+
if (request.url && /^[a-z][a-z0-9+.-]*:/i.test(request.url)) {
|
|
88
|
+
assertOrigin(new URL(request.url));
|
|
89
|
+
}
|
|
90
|
+
assertOrigin(new URL(client.getUri(request)));
|
|
91
|
+
// Unlike socket timeouts, this deadline also bounds a continuously trickling response.
|
|
92
|
+
const deadline = AbortSignal.timeout(requestTimeoutMs);
|
|
93
|
+
request.signal = request.signal
|
|
94
|
+
? AbortSignal.any([request.signal, deadline])
|
|
95
|
+
: deadline;
|
|
96
|
+
return request;
|
|
97
|
+
});
|
|
98
|
+
return client;
|
|
99
|
+
}
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { Command } from 'commander';
|
|
3
|
+
import { ConfigLoader } from './config-loader.js';
|
|
4
|
+
import { ConfigurationError } from './config-file.js';
|
|
5
|
+
import { startServer } from './server.js';
|
|
6
|
+
const program = new Command()
|
|
7
|
+
.name('redmine-mcp-ro')
|
|
8
|
+
.description('Read-only Redmine MCP server using a local configuration and system credential store')
|
|
9
|
+
.version('0.3.0')
|
|
10
|
+
.option('-d, --config-dir <directory>', 'Directory containing .redmine-mcp (default: REDMINE_CONFIG_DIR or current directory)')
|
|
11
|
+
.option('-p, --profile <name>', 'Local profile (default: REDMINE_PROFILE or configured default)')
|
|
12
|
+
.parse();
|
|
13
|
+
try {
|
|
14
|
+
const options = program.opts();
|
|
15
|
+
const config = await new ConfigLoader({ directory: options.configDir, profile: options.profile }).getConfig();
|
|
16
|
+
if (!config)
|
|
17
|
+
throw new ConfigurationError('No local configuration. Run redmine-mcp configure --config-dir <directory>, then pass the same directory to redmine-mcp-ro.');
|
|
18
|
+
console.error(`Using Redmine configuration from: ${config.source}`);
|
|
19
|
+
console.error(`Profile: ${config.profile}`);
|
|
20
|
+
await startServer(config);
|
|
21
|
+
}
|
|
22
|
+
catch (error) {
|
|
23
|
+
console.error(error instanceof ConfigurationError ? error.message : 'MCP startup failed. Check configuration, request limits and system credential store access.');
|
|
24
|
+
process.exitCode = 1;
|
|
25
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { ConfigFile, CredentialStore, RedmineProfile } from './types.js';
|
|
2
|
+
export interface ConfigurationOptions {
|
|
3
|
+
directory?: string;
|
|
4
|
+
profile?: string;
|
|
5
|
+
store?: CredentialStore;
|
|
6
|
+
legacyStore?: CredentialStore;
|
|
7
|
+
}
|
|
8
|
+
export interface ConfigChange {
|
|
9
|
+
config: ConfigFile;
|
|
10
|
+
warnings: string[];
|
|
11
|
+
profileRenames?: Array<{
|
|
12
|
+
from: string;
|
|
13
|
+
to: string;
|
|
14
|
+
}>;
|
|
15
|
+
}
|
|
16
|
+
export declare function validateApiKey(value: string): void;
|
|
17
|
+
export interface Connection {
|
|
18
|
+
url: string;
|
|
19
|
+
apiKey: string;
|
|
20
|
+
}
|
|
21
|
+
export declare function parseConnection(raw: string | null): Connection;
|
|
22
|
+
export declare function readConnection(store: CredentialStore, profile: RedmineProfile): Promise<Connection>;
|
|
23
|
+
export declare class LocalConfiguration {
|
|
24
|
+
readonly configPath: string;
|
|
25
|
+
readonly store: CredentialStore;
|
|
26
|
+
private readonly legacyStore?;
|
|
27
|
+
constructor(options?: ConfigurationOptions);
|
|
28
|
+
read(): ConfigFile | null;
|
|
29
|
+
private cleanup;
|
|
30
|
+
private createCredential;
|
|
31
|
+
configure(name: string, url: string, apiKey: string): Promise<ConfigChange>;
|
|
32
|
+
migrate(): Promise<ConfigChange>;
|
|
33
|
+
useProfile(name: string): Promise<void>;
|
|
34
|
+
remove(name: string): Promise<ConfigChange>;
|
|
35
|
+
}
|