@atlassian-dc-mcp/common 0.17.0 → 0.18.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/CHANGELOG.md +23 -0
- package/build/__tests__/describe-error.test.d.ts +2 -0
- package/build/__tests__/describe-error.test.d.ts.map +1 -0
- package/build/__tests__/describe-error.test.js +54 -0
- package/build/__tests__/describe-error.test.js.map +1 -0
- package/build/__tests__/setup-cli.test.js +151 -44
- package/build/__tests__/setup-cli.test.js.map +1 -1
- package/build/config/__tests__/resolve-base.test.d.ts +2 -0
- package/build/config/__tests__/resolve-base.test.d.ts.map +1 -0
- package/build/config/__tests__/resolve-base.test.js +89 -0
- package/build/config/__tests__/resolve-base.test.js.map +1 -0
- package/build/config/index.d.ts +1 -0
- package/build/config/index.d.ts.map +1 -1
- package/build/config/index.js +1 -0
- package/build/config/index.js.map +1 -1
- package/build/config/resolve-base.d.ts +8 -0
- package/build/config/resolve-base.d.ts.map +1 -0
- package/build/config/resolve-base.js +31 -0
- package/build/config/resolve-base.js.map +1 -0
- package/build/config/source.d.ts +1 -0
- package/build/config/source.d.ts.map +1 -1
- package/build/config/source.js.map +1 -1
- package/build/index.d.ts +1 -0
- package/build/index.d.ts.map +1 -1
- package/build/index.js +1 -0
- package/build/index.js.map +1 -1
- package/build/setup/describe-error.d.ts +2 -0
- package/build/setup/describe-error.d.ts.map +1 -0
- package/build/setup/describe-error.js +50 -0
- package/build/setup/describe-error.js.map +1 -0
- package/build/setup/value-validator.d.ts +8 -0
- package/build/setup/value-validator.d.ts.map +1 -0
- package/build/setup/value-validator.js +57 -0
- package/build/setup/value-validator.js.map +1 -0
- package/build/setup-cli.d.ts +15 -0
- package/build/setup-cli.d.ts.map +1 -1
- package/build/setup-cli.js +140 -27
- package/build/setup-cli.js.map +1 -1
- package/jest.config.js +1 -0
- package/package.json +2 -2
- package/src/__tests__/describe-error.test.ts +70 -0
- package/src/__tests__/setup-cli.test.ts +181 -38
- package/src/config/__tests__/resolve-base.test.ts +101 -0
- package/src/config/index.ts +1 -0
- package/src/config/resolve-base.ts +43 -0
- package/src/config/source.ts +1 -0
- package/src/index.ts +1 -0
- package/src/setup/describe-error.ts +67 -0
- package/src/setup/value-validator.ts +59 -0
- package/src/setup-cli.ts +191 -30
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -2,7 +2,13 @@ import { DefaultConfigRegistry } from '../config/registry.js';
|
|
|
2
2
|
import { HomeFileSource } from '../config/sources/home-file.js';
|
|
3
3
|
import { MacosKeychainSource, type KeychainDeps } from '../config/sources/macos-keychain.js';
|
|
4
4
|
import { ProcessEnvSource } from '../config/sources/process-env.js';
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
runSetup,
|
|
7
|
+
type CredentialValidationContext,
|
|
8
|
+
type CredentialValidationResult,
|
|
9
|
+
type SetupPrompts,
|
|
10
|
+
type ValidateCredentials,
|
|
11
|
+
} from '../setup-cli.js';
|
|
6
12
|
import type { ConfigKey, ProductDefinition } from '../config/source.js';
|
|
7
13
|
|
|
8
14
|
const JIRA: ProductDefinition = {
|
|
@@ -72,15 +78,69 @@ class FakeHomeFile extends HomeFileSource {
|
|
|
72
78
|
}
|
|
73
79
|
}
|
|
74
80
|
|
|
81
|
+
class StubCredentialValidator {
|
|
82
|
+
readonly calls: CredentialValidationContext[] = [];
|
|
83
|
+
private readonly queue: CredentialValidationResult[] = [];
|
|
84
|
+
private fallback: CredentialValidationResult = { ok: true };
|
|
85
|
+
|
|
86
|
+
returns(result: CredentialValidationResult): this {
|
|
87
|
+
this.fallback = result;
|
|
88
|
+
return this;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
reject(message: string): this {
|
|
92
|
+
return this.returns({ ok: false, message });
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
enqueue(...results: CredentialValidationResult[]): this {
|
|
96
|
+
this.queue.push(...results);
|
|
97
|
+
return this;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
asFn(): ValidateCredentials {
|
|
101
|
+
return async (ctx) => {
|
|
102
|
+
this.calls.push(ctx);
|
|
103
|
+
return this.queue.shift() ?? this.fallback;
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
type ConfirmStub = (message: string, fallbackDefault?: boolean) => boolean;
|
|
109
|
+
|
|
110
|
+
function scriptedConfirms(script: Record<string, boolean>): ConfirmStub {
|
|
111
|
+
return (message, fallbackDefault) => {
|
|
112
|
+
for (const [prefix, answer] of Object.entries(script)) {
|
|
113
|
+
if (message.startsWith(prefix)) return answer;
|
|
114
|
+
}
|
|
115
|
+
return fallbackDefault ?? false;
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
75
119
|
function makeRegistry(keychain: FakeKeychain, home: FakeHomeFile) {
|
|
76
120
|
return new DefaultConfigRegistry([new ProcessEnvSource(), home, keychain]);
|
|
77
121
|
}
|
|
78
122
|
|
|
79
|
-
|
|
123
|
+
const standardAnswers = {
|
|
124
|
+
host: 'j-host',
|
|
125
|
+
apiBasePath: '/rest/api/2',
|
|
126
|
+
pageSize: '25',
|
|
127
|
+
token: 'secret',
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
function makePrompts(
|
|
131
|
+
overrides: Partial<SetupPrompts> = {},
|
|
132
|
+
answers: Partial<typeof standardAnswers> = {},
|
|
133
|
+
confirms?: ConfirmStub,
|
|
134
|
+
): SetupPrompts {
|
|
135
|
+
const filled = { ...standardAnswers, ...answers };
|
|
80
136
|
return {
|
|
81
|
-
input: async (opts) =>
|
|
82
|
-
|
|
83
|
-
|
|
137
|
+
input: async (opts) => {
|
|
138
|
+
if (opts.message.startsWith('Host')) return filled.host;
|
|
139
|
+
if (opts.message.startsWith('API base path')) return filled.apiBasePath;
|
|
140
|
+
return filled.pageSize;
|
|
141
|
+
},
|
|
142
|
+
password: async () => filled.token,
|
|
143
|
+
confirm: async (opts) => (confirms ?? ((_m, d) => d ?? false))(opts.message, opts.default),
|
|
84
144
|
...overrides,
|
|
85
145
|
};
|
|
86
146
|
}
|
|
@@ -105,16 +165,13 @@ describe('runSetup', () => {
|
|
|
105
165
|
});
|
|
106
166
|
|
|
107
167
|
it('writes the token to keychain first on darwin and clears home file token', async () => {
|
|
108
|
-
const prompts = makePrompts({
|
|
109
|
-
input: async (opts) => {
|
|
110
|
-
if (opts.message.startsWith('Host')) return 'j-host';
|
|
111
|
-
if (opts.message.startsWith('API base path')) return '/rest/api/2';
|
|
112
|
-
return '25';
|
|
113
|
-
},
|
|
114
|
-
password: async () => 'secret',
|
|
115
|
-
});
|
|
116
168
|
const registry = makeRegistry(keychain, home);
|
|
117
|
-
await runSetup(JIRA, {
|
|
169
|
+
await runSetup(JIRA, {
|
|
170
|
+
registry,
|
|
171
|
+
log: (m) => logs.push(m),
|
|
172
|
+
exit: () => undefined,
|
|
173
|
+
prompts: makePrompts(),
|
|
174
|
+
});
|
|
118
175
|
|
|
119
176
|
expect(keychain.writeCalls).toBe(1);
|
|
120
177
|
expect(keychain.store).toBe('secret');
|
|
@@ -124,16 +181,13 @@ describe('runSetup', () => {
|
|
|
124
181
|
|
|
125
182
|
it('falls back to home file when keychain is unavailable', async () => {
|
|
126
183
|
keychain.available = false;
|
|
127
|
-
const prompts = makePrompts({
|
|
128
|
-
input: async (opts) => {
|
|
129
|
-
if (opts.message.startsWith('Host')) return 'j-host';
|
|
130
|
-
if (opts.message.startsWith('API base path')) return '/rest/api/2';
|
|
131
|
-
return '25';
|
|
132
|
-
},
|
|
133
|
-
password: async () => 'secret',
|
|
134
|
-
});
|
|
135
184
|
const registry = makeRegistry(keychain, home);
|
|
136
|
-
await runSetup(JIRA, {
|
|
185
|
+
await runSetup(JIRA, {
|
|
186
|
+
registry,
|
|
187
|
+
log: (m) => logs.push(m),
|
|
188
|
+
exit: () => undefined,
|
|
189
|
+
prompts: makePrompts(),
|
|
190
|
+
});
|
|
137
191
|
|
|
138
192
|
expect(keychain.writeCalls).toBe(0);
|
|
139
193
|
const tokenWrites = home.writes.filter(([, k]) => k === 'token');
|
|
@@ -142,32 +196,121 @@ describe('runSetup', () => {
|
|
|
142
196
|
|
|
143
197
|
it('prints shadowing warning when env var is set higher than target', async () => {
|
|
144
198
|
process.env.JIRA_API_TOKEN = 'shadow';
|
|
145
|
-
const prompts = makePrompts({
|
|
146
|
-
input: async (opts) => {
|
|
147
|
-
if (opts.message.startsWith('Host')) return 'j-host';
|
|
148
|
-
if (opts.message.startsWith('API base path')) return '/rest/api/2';
|
|
149
|
-
return '25';
|
|
150
|
-
},
|
|
151
|
-
password: async () => 'secret',
|
|
152
|
-
});
|
|
153
199
|
const registry = makeRegistry(keychain, home);
|
|
154
|
-
await runSetup(JIRA, {
|
|
200
|
+
await runSetup(JIRA, {
|
|
201
|
+
registry,
|
|
202
|
+
log: (m) => logs.push(m),
|
|
203
|
+
exit: () => undefined,
|
|
204
|
+
prompts: makePrompts(),
|
|
205
|
+
});
|
|
155
206
|
|
|
156
207
|
expect(logs.some((l) => l.includes('JIRA_API_TOKEN') && l.includes('Warning'))).toBe(true);
|
|
157
208
|
});
|
|
158
209
|
|
|
210
|
+
it('calls validateCredentials with the entered values before saving', async () => {
|
|
211
|
+
const validator = new StubCredentialValidator();
|
|
212
|
+
const registry = makeRegistry(keychain, home);
|
|
213
|
+
|
|
214
|
+
await runSetup(JIRA, {
|
|
215
|
+
registry,
|
|
216
|
+
log: (m) => logs.push(m),
|
|
217
|
+
exit: () => undefined,
|
|
218
|
+
prompts: makePrompts(),
|
|
219
|
+
validateCredentials: validator.asFn(),
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
expect(validator.calls).toEqual([
|
|
223
|
+
{ host: 'j-host', apiBasePath: '/rest/api/2', token: 'secret' },
|
|
224
|
+
]);
|
|
225
|
+
expect(keychain.writeCalls).toBe(1);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it('exits without writing when validation fails and user declines retry or save-anyway', async () => {
|
|
229
|
+
const validator = new StubCredentialValidator().reject('401 Unauthorized');
|
|
230
|
+
const exitFn = jest.fn();
|
|
231
|
+
const registry = makeRegistry(keychain, home);
|
|
232
|
+
|
|
233
|
+
await runSetup(JIRA, {
|
|
234
|
+
registry,
|
|
235
|
+
log: (m) => logs.push(m),
|
|
236
|
+
exit: exitFn,
|
|
237
|
+
prompts: makePrompts({}, { token: 'bad-token' }, scriptedConfirms({
|
|
238
|
+
'Try again': false,
|
|
239
|
+
'Save configuration anyway': false,
|
|
240
|
+
})),
|
|
241
|
+
validateCredentials: validator.asFn(),
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
expect(validator.calls).toHaveLength(1);
|
|
245
|
+
expect(exitFn).toHaveBeenCalledWith(1);
|
|
246
|
+
expect(keychain.writeCalls).toBe(0);
|
|
247
|
+
expect(home.writes).toHaveLength(0);
|
|
248
|
+
expect(logs.some((l) => l.includes('401 Unauthorized'))).toBe(true);
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it('retries validation after a failure and writes once it succeeds', async () => {
|
|
252
|
+
const validator = new StubCredentialValidator().enqueue(
|
|
253
|
+
{ ok: false, message: '401 Unauthorized' },
|
|
254
|
+
{ ok: true },
|
|
255
|
+
);
|
|
256
|
+
const registry = makeRegistry(keychain, home);
|
|
257
|
+
|
|
258
|
+
await runSetup(JIRA, {
|
|
259
|
+
registry,
|
|
260
|
+
log: (m) => logs.push(m),
|
|
261
|
+
exit: () => undefined,
|
|
262
|
+
prompts: makePrompts({}, {}, scriptedConfirms({ 'Try again': true })),
|
|
263
|
+
validateCredentials: validator.asFn(),
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
expect(validator.calls).toHaveLength(2);
|
|
267
|
+
expect(keychain.writeCalls).toBe(1);
|
|
268
|
+
expect(logs.some((l) => l.includes('401 Unauthorized'))).toBe(true);
|
|
269
|
+
expect(logs.some((l) => l.startsWith('Validation succeeded'))).toBe(true);
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it('stops offering retry after the third failure and lets the user save anyway', async () => {
|
|
273
|
+
const validator = new StubCredentialValidator().reject('401 Unauthorized');
|
|
274
|
+
let retryPromptsShown = 0;
|
|
275
|
+
const confirms: ConfirmStub = (message) => {
|
|
276
|
+
if (message.startsWith('Try again')) {
|
|
277
|
+
retryPromptsShown++;
|
|
278
|
+
return true;
|
|
279
|
+
}
|
|
280
|
+
if (message.startsWith('Save configuration anyway')) return true;
|
|
281
|
+
return false;
|
|
282
|
+
};
|
|
283
|
+
const registry = makeRegistry(keychain, home);
|
|
284
|
+
|
|
285
|
+
await runSetup(JIRA, {
|
|
286
|
+
registry,
|
|
287
|
+
log: (m) => logs.push(m),
|
|
288
|
+
exit: () => undefined,
|
|
289
|
+
prompts: makePrompts({}, {}, confirms),
|
|
290
|
+
validateCredentials: validator.asFn(),
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
expect(validator.calls).toHaveLength(3);
|
|
294
|
+
expect(retryPromptsShown).toBe(2);
|
|
295
|
+
expect(keychain.writeCalls).toBe(1);
|
|
296
|
+
});
|
|
297
|
+
|
|
159
298
|
it('exits 130 on SIGINT cancellation before any write', async () => {
|
|
160
299
|
const exitErr: Error & { name: string } = Object.assign(new Error('closed'), {
|
|
161
300
|
name: 'ExitPromptError',
|
|
162
301
|
});
|
|
163
|
-
const prompts = makePrompts({
|
|
164
|
-
input: async () => {
|
|
165
|
-
throw exitErr;
|
|
166
|
-
},
|
|
167
|
-
});
|
|
168
302
|
const exitFn = jest.fn();
|
|
169
303
|
const registry = makeRegistry(keychain, home);
|
|
170
|
-
await runSetup(JIRA, {
|
|
304
|
+
await runSetup(JIRA, {
|
|
305
|
+
registry,
|
|
306
|
+
log: (m) => logs.push(m),
|
|
307
|
+
exit: exitFn,
|
|
308
|
+
prompts: makePrompts({
|
|
309
|
+
input: async () => {
|
|
310
|
+
throw exitErr;
|
|
311
|
+
},
|
|
312
|
+
}),
|
|
313
|
+
});
|
|
171
314
|
expect(exitFn).toHaveBeenCalledWith(130);
|
|
172
315
|
expect(keychain.writeCalls).toBe(0);
|
|
173
316
|
expect(home.writes).toHaveLength(0);
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { resolveOpenApiBase } from '../resolve-base.js';
|
|
2
|
+
|
|
3
|
+
describe('resolveOpenApiBase', () => {
|
|
4
|
+
it('combines bare host with default base path', () => {
|
|
5
|
+
const base = resolveOpenApiBase({
|
|
6
|
+
host: 'jira.example.com',
|
|
7
|
+
defaultBasePath: '/rest',
|
|
8
|
+
});
|
|
9
|
+
expect(base).toBe('https://jira.example.com/rest');
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it('uses an explicit apiBasePath when it is a path', () => {
|
|
13
|
+
const base = resolveOpenApiBase({
|
|
14
|
+
host: 'jira.example.com',
|
|
15
|
+
apiBasePath: '/custom',
|
|
16
|
+
defaultBasePath: '/rest',
|
|
17
|
+
});
|
|
18
|
+
expect(base).toBe('https://jira.example.com/custom');
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('uses an empty default for confluence-style generated paths', () => {
|
|
22
|
+
const base = resolveOpenApiBase({
|
|
23
|
+
host: 'wiki.example.com',
|
|
24
|
+
defaultBasePath: '',
|
|
25
|
+
});
|
|
26
|
+
expect(base).toBe('https://wiki.example.com');
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('accepts a fully-qualified apiBasePath as a full URL override', () => {
|
|
30
|
+
const base = resolveOpenApiBase({
|
|
31
|
+
host: 'ignored.example.com',
|
|
32
|
+
apiBasePath: 'https://real.example.com/rest',
|
|
33
|
+
defaultBasePath: '/rest',
|
|
34
|
+
});
|
|
35
|
+
expect(base).toBe('https://real.example.com/rest');
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('strips known generated suffix from a fully-qualified apiBasePath', () => {
|
|
39
|
+
const base = resolveOpenApiBase({
|
|
40
|
+
host: 'ignored.example.com',
|
|
41
|
+
apiBasePath: 'https://real.example.com/rest/api/2',
|
|
42
|
+
defaultBasePath: '/rest',
|
|
43
|
+
strippableSuffixes: ['/api/2'],
|
|
44
|
+
});
|
|
45
|
+
expect(base).toBe('https://real.example.com/rest');
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('strips a confluence generated suffix from a fully-qualified apiBasePath with a context path', () => {
|
|
49
|
+
const base = resolveOpenApiBase({
|
|
50
|
+
host: 'ignored.example.com',
|
|
51
|
+
apiBasePath: 'https://wiki.example.com/confluence/rest/api',
|
|
52
|
+
defaultBasePath: '',
|
|
53
|
+
strippableSuffixes: ['/rest/api', '/rest'],
|
|
54
|
+
});
|
|
55
|
+
expect(base).toBe('https://wiki.example.com/confluence');
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('strips known generated suffix included by mistake (jira)', () => {
|
|
59
|
+
const base = resolveOpenApiBase({
|
|
60
|
+
host: 'jira.example.com',
|
|
61
|
+
apiBasePath: '/rest/api/2',
|
|
62
|
+
defaultBasePath: '/rest',
|
|
63
|
+
strippableSuffixes: ['/api/2'],
|
|
64
|
+
});
|
|
65
|
+
expect(base).toBe('https://jira.example.com/rest');
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('strips known generated suffix included by mistake (bitbucket)', () => {
|
|
69
|
+
const base = resolveOpenApiBase({
|
|
70
|
+
host: 'bb.example.com',
|
|
71
|
+
apiBasePath: '/rest/api/1.0',
|
|
72
|
+
defaultBasePath: '/rest',
|
|
73
|
+
strippableSuffixes: ['/api/1.0', '/api/latest'],
|
|
74
|
+
});
|
|
75
|
+
expect(base).toBe('https://bb.example.com/rest');
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('strips known generated suffix included by mistake (confluence)', () => {
|
|
79
|
+
const base = resolveOpenApiBase({
|
|
80
|
+
host: 'wiki.example.com',
|
|
81
|
+
apiBasePath: '/rest/api',
|
|
82
|
+
defaultBasePath: '',
|
|
83
|
+
strippableSuffixes: ['/rest/api', '/rest'],
|
|
84
|
+
});
|
|
85
|
+
expect(base).toBe('https://wiki.example.com');
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('preserves a host that already includes a scheme', () => {
|
|
89
|
+
const base = resolveOpenApiBase({
|
|
90
|
+
host: 'https://jira.example.com/',
|
|
91
|
+
defaultBasePath: '/rest',
|
|
92
|
+
});
|
|
93
|
+
expect(base).toBe('https://jira.example.com/rest');
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('throws when host and apiBasePath are both missing', () => {
|
|
97
|
+
expect(() =>
|
|
98
|
+
resolveOpenApiBase({ defaultBasePath: '/rest' }),
|
|
99
|
+
).toThrow('host or apiBasePath must be provided');
|
|
100
|
+
});
|
|
101
|
+
});
|
package/src/config/index.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
export * from './source.js';
|
|
2
2
|
export * from './registry.js';
|
|
3
3
|
export * from './runtime-config.js';
|
|
4
|
+
export * from './resolve-base.js';
|
|
4
5
|
export { ProcessEnvSource } from './sources/process-env.js';
|
|
5
6
|
export { EnvFileSource, ATLASSIAN_DC_MCP_CONFIG_FILE_ENV_VAR } from './sources/env-file.js';
|
|
6
7
|
export { HomeFileSource, getHomeFilePath, HOME_DIR_NAME } from './sources/home-file.js';
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
export type ResolveOpenApiBaseOptions = {
|
|
2
|
+
host?: string;
|
|
3
|
+
apiBasePath?: string;
|
|
4
|
+
defaultBasePath: string;
|
|
5
|
+
strippableSuffixes?: readonly string[];
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
function normalizeHost(host: string): string {
|
|
9
|
+
const withScheme = /^https?:\/\//i.test(host) ? host : `https://${host}`;
|
|
10
|
+
return withScheme.replace(/\/+$/, '');
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function stripGeneratedSuffix(path: string, suffixes: readonly string[]): string {
|
|
14
|
+
for (const suffix of suffixes) {
|
|
15
|
+
const escaped = suffix.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
16
|
+
const re = new RegExp(`${escaped}\\/?$`, 'i');
|
|
17
|
+
if (re.test(path)) {
|
|
18
|
+
return path.replace(re, '');
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
return path;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function normalizeBasePath(path: string, suffixes: readonly string[]): string {
|
|
25
|
+
const stripped = stripGeneratedSuffix(path, suffixes).replace(/\/+$/, '');
|
|
26
|
+
return stripped === '/' ? '' : stripped;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function resolveOpenApiBase(options: ResolveOpenApiBaseOptions): string {
|
|
30
|
+
const { host, apiBasePath, defaultBasePath, strippableSuffixes = [] } = options;
|
|
31
|
+
|
|
32
|
+
if (apiBasePath && /^https?:\/\//i.test(apiBasePath)) {
|
|
33
|
+
const url = new URL(apiBasePath);
|
|
34
|
+
return `${url.origin}${normalizeBasePath(url.pathname, strippableSuffixes)}`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (!host) {
|
|
38
|
+
throw new Error('host or apiBasePath must be provided');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const basePath = apiBasePath ? normalizeBasePath(apiBasePath, strippableSuffixes) : defaultBasePath;
|
|
42
|
+
return `${normalizeHost(host)}${basePath}`;
|
|
43
|
+
}
|
package/src/config/source.ts
CHANGED
package/src/index.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
|
|
|
4
4
|
export * from './api-error-handler.js'
|
|
5
5
|
export * from './config/index.js';
|
|
6
6
|
export { runSetup } from './setup-cli.js';
|
|
7
|
+
export { describeValidationError } from './setup/describe-error.js';
|
|
7
8
|
|
|
8
9
|
// Helper function to format tool responses
|
|
9
10
|
export const formatToolResponse = (result: unknown) => ({
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
const NETWORK_CODE_HINTS: Record<string, string> = {
|
|
2
|
+
ENOTFOUND: 'could not resolve host',
|
|
3
|
+
EAI_AGAIN: 'DNS lookup temporarily failed',
|
|
4
|
+
ECONNREFUSED: 'connection refused',
|
|
5
|
+
ECONNRESET: 'connection reset by peer',
|
|
6
|
+
ETIMEDOUT: 'connection timed out',
|
|
7
|
+
ECONNABORTED: 'connection aborted',
|
|
8
|
+
EHOSTUNREACH: 'host is unreachable',
|
|
9
|
+
ENETUNREACH: 'network is unreachable',
|
|
10
|
+
EPROTO: 'TLS protocol error',
|
|
11
|
+
CERT_HAS_EXPIRED: 'server TLS certificate has expired',
|
|
12
|
+
DEPTH_ZERO_SELF_SIGNED_CERT: 'server uses a self-signed TLS certificate',
|
|
13
|
+
SELF_SIGNED_CERT_IN_CHAIN: 'server TLS certificate chain is self-signed',
|
|
14
|
+
UNABLE_TO_VERIFY_LEAF_SIGNATURE: 'server TLS certificate could not be verified',
|
|
15
|
+
ERR_TLS_CERT_ALTNAME_INVALID: 'server TLS certificate does not match the host name',
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
type ApiLikeError = {
|
|
19
|
+
status: number;
|
|
20
|
+
statusText?: string;
|
|
21
|
+
url?: string;
|
|
22
|
+
body?: unknown;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
type CauseLikeError = {
|
|
26
|
+
code?: string;
|
|
27
|
+
message?: string;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export function describeValidationError(error: unknown): string {
|
|
31
|
+
if (isApiLikeError(error)) {
|
|
32
|
+
return formatApiError(error);
|
|
33
|
+
}
|
|
34
|
+
if (isAbortError(error)) {
|
|
35
|
+
return 'request was aborted (possibly timed out)';
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const err = error as { message?: string; cause?: CauseLikeError } | undefined;
|
|
39
|
+
const cause = err?.cause;
|
|
40
|
+
if (cause?.code) {
|
|
41
|
+
const hint = NETWORK_CODE_HINTS[cause.code] ?? 'network error';
|
|
42
|
+
const detail = cause.message ?? err?.message ?? 'unknown';
|
|
43
|
+
return `${hint} (${cause.code}: ${detail})`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return err?.message ?? String(error);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function formatApiError(error: ApiLikeError): string {
|
|
50
|
+
const status = `${error.status}${error.statusText ? ` ${error.statusText}` : ''}`.trim();
|
|
51
|
+
const url = error.url ? `${error.url} ` : '';
|
|
52
|
+
const hint = status.startsWith('401') || status.startsWith('403')
|
|
53
|
+
? ' Check the host, API base path, and API token.'
|
|
54
|
+
: '';
|
|
55
|
+
return `${url}returned ${status}.${hint}`.trim();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function isApiLikeError(error: unknown): error is ApiLikeError {
|
|
59
|
+
return Boolean(error)
|
|
60
|
+
&& typeof error === 'object'
|
|
61
|
+
&& 'status' in (error as object)
|
|
62
|
+
&& typeof (error as ApiLikeError).status === 'number';
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function isAbortError(error: unknown): boolean {
|
|
66
|
+
return Boolean(error && typeof error === 'object' && (error as { name?: string }).name === 'AbortError');
|
|
67
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
export type ValidationResult = true | string;
|
|
2
|
+
|
|
3
|
+
export class SetupValueValidator {
|
|
4
|
+
static host(raw: string): ValidationResult {
|
|
5
|
+
const value = raw.trim();
|
|
6
|
+
if (value.length === 0) {
|
|
7
|
+
return true;
|
|
8
|
+
}
|
|
9
|
+
if (/\s/.test(value)) {
|
|
10
|
+
return 'must not contain whitespace';
|
|
11
|
+
}
|
|
12
|
+
try {
|
|
13
|
+
const url = new URL(/^https?:\/\//i.test(value) ? value : `https://${value}`);
|
|
14
|
+
if (!url.hostname) {
|
|
15
|
+
return 'must include a host name';
|
|
16
|
+
}
|
|
17
|
+
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
|
18
|
+
return 'must use http or https';
|
|
19
|
+
}
|
|
20
|
+
return true;
|
|
21
|
+
} catch {
|
|
22
|
+
return 'enter a host name or http(s) URL';
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
static apiBasePath(raw: string): ValidationResult {
|
|
27
|
+
const value = raw.trim();
|
|
28
|
+
if (value.length === 0) {
|
|
29
|
+
return true;
|
|
30
|
+
}
|
|
31
|
+
if (/\s/.test(value)) {
|
|
32
|
+
return 'must not contain whitespace';
|
|
33
|
+
}
|
|
34
|
+
if (/^https?:\/\//i.test(value)) {
|
|
35
|
+
try {
|
|
36
|
+
new URL(value);
|
|
37
|
+
return true;
|
|
38
|
+
} catch {
|
|
39
|
+
return 'enter a valid http(s) URL';
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return value.startsWith('/') ? true : 'enter a path starting with / or a full http(s) URL';
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
static token(raw: string): ValidationResult {
|
|
46
|
+
const value = raw.trim();
|
|
47
|
+
if (value.length === 0) {
|
|
48
|
+
return true;
|
|
49
|
+
}
|
|
50
|
+
return /\s/.test(value) ? 'must not contain whitespace' : true;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
static pageSize(raw: string): ValidationResult {
|
|
54
|
+
const trimmed = raw.trim();
|
|
55
|
+
return /^\d+$/.test(trimmed) && Number.parseInt(trimmed, 10) > 0
|
|
56
|
+
? true
|
|
57
|
+
: 'enter a positive integer';
|
|
58
|
+
}
|
|
59
|
+
}
|