@teamso/teamso-mcp 0.0.0-stage → 1.2.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.
Files changed (112) hide show
  1. package/CHANGELOG.md +121 -0
  2. package/LICENSE +21 -0
  3. package/README.md +516 -2
  4. package/dist/client.d.ts +215 -0
  5. package/dist/client.d.ts.map +1 -0
  6. package/dist/client.js +797 -0
  7. package/dist/client.js.map +1 -0
  8. package/dist/index.d.ts +3 -0
  9. package/dist/index.d.ts.map +1 -0
  10. package/dist/index.js +32 -0
  11. package/dist/index.js.map +1 -0
  12. package/dist/prompts/index.d.ts +12 -0
  13. package/dist/prompts/index.d.ts.map +1 -0
  14. package/dist/prompts/index.js +58 -0
  15. package/dist/prompts/index.js.map +1 -0
  16. package/dist/server.d.ts +28 -0
  17. package/dist/server.d.ts.map +1 -0
  18. package/dist/server.js +58 -0
  19. package/dist/server.js.map +1 -0
  20. package/dist/tools/account.d.ts +4 -0
  21. package/dist/tools/account.d.ts.map +1 -0
  22. package/dist/tools/account.js +34 -0
  23. package/dist/tools/account.js.map +1 -0
  24. package/dist/tools/api.d.ts +13 -0
  25. package/dist/tools/api.d.ts.map +1 -0
  26. package/dist/tools/api.js +980 -0
  27. package/dist/tools/api.js.map +1 -0
  28. package/dist/tools/approvals.d.ts +5 -0
  29. package/dist/tools/approvals.d.ts.map +1 -0
  30. package/dist/tools/approvals.js +120 -0
  31. package/dist/tools/approvals.js.map +1 -0
  32. package/dist/tools/communication.d.ts +4 -0
  33. package/dist/tools/communication.d.ts.map +1 -0
  34. package/dist/tools/communication.js +39 -0
  35. package/dist/tools/communication.js.map +1 -0
  36. package/dist/tools/employees.d.ts +5 -0
  37. package/dist/tools/employees.d.ts.map +1 -0
  38. package/dist/tools/employees.js +113 -0
  39. package/dist/tools/employees.js.map +1 -0
  40. package/dist/tools/help.d.ts +6 -0
  41. package/dist/tools/help.d.ts.map +1 -0
  42. package/dist/tools/help.js +171 -0
  43. package/dist/tools/help.js.map +1 -0
  44. package/dist/tools/index.d.ts +86 -0
  45. package/dist/tools/index.d.ts.map +1 -0
  46. package/dist/tools/index.js +118 -0
  47. package/dist/tools/index.js.map +1 -0
  48. package/dist/tools/leave-support.d.ts +60 -0
  49. package/dist/tools/leave-support.d.ts.map +1 -0
  50. package/dist/tools/leave-support.js +185 -0
  51. package/dist/tools/leave-support.js.map +1 -0
  52. package/dist/tools/leave.d.ts +14 -0
  53. package/dist/tools/leave.d.ts.map +1 -0
  54. package/dist/tools/leave.js +386 -0
  55. package/dist/tools/leave.js.map +1 -0
  56. package/dist/tools/payroll.d.ts +5 -0
  57. package/dist/tools/payroll.d.ts.map +1 -0
  58. package/dist/tools/payroll.js +101 -0
  59. package/dist/tools/payroll.js.map +1 -0
  60. package/dist/tools/performance.d.ts +13 -0
  61. package/dist/tools/performance.d.ts.map +1 -0
  62. package/dist/tools/performance.js +592 -0
  63. package/dist/tools/performance.js.map +1 -0
  64. package/dist/tools/schemas.d.ts +2079 -0
  65. package/dist/tools/schemas.d.ts.map +1 -0
  66. package/dist/tools/schemas.js +460 -0
  67. package/dist/tools/schemas.js.map +1 -0
  68. package/dist/tools/shared.d.ts +20 -0
  69. package/dist/tools/shared.d.ts.map +1 -0
  70. package/dist/tools/shared.js +64 -0
  71. package/dist/tools/shared.js.map +1 -0
  72. package/dist/types.d.ts +545 -0
  73. package/dist/types.d.ts.map +1 -0
  74. package/dist/types.js +7 -0
  75. package/dist/types.js.map +1 -0
  76. package/dist/utils/api-endpoints.d.ts +130 -0
  77. package/dist/utils/api-endpoints.d.ts.map +1 -0
  78. package/dist/utils/api-endpoints.js +459 -0
  79. package/dist/utils/api-endpoints.js.map +1 -0
  80. package/dist/utils/dates.d.ts +27 -0
  81. package/dist/utils/dates.d.ts.map +1 -0
  82. package/dist/utils/dates.js +102 -0
  83. package/dist/utils/dates.js.map +1 -0
  84. package/dist/utils/format.d.ts +8 -0
  85. package/dist/utils/format.d.ts.map +1 -0
  86. package/dist/utils/format.js +26 -0
  87. package/dist/utils/format.js.map +1 -0
  88. package/dist/utils/help-search.d.ts +25 -0
  89. package/dist/utils/help-search.d.ts.map +1 -0
  90. package/dist/utils/help-search.js +93 -0
  91. package/dist/utils/help-search.js.map +1 -0
  92. package/dist/utils/json-schema-dialect.d.ts +12 -0
  93. package/dist/utils/json-schema-dialect.d.ts.map +1 -0
  94. package/dist/utils/json-schema-dialect.js +146 -0
  95. package/dist/utils/json-schema-dialect.js.map +1 -0
  96. package/dist/utils/results.d.ts +27 -0
  97. package/dist/utils/results.d.ts.map +1 -0
  98. package/dist/utils/results.js +88 -0
  99. package/dist/utils/results.js.map +1 -0
  100. package/dist/utils/structured.d.ts +89 -0
  101. package/dist/utils/structured.d.ts.map +1 -0
  102. package/dist/utils/structured.js +307 -0
  103. package/dist/utils/structured.js.map +1 -0
  104. package/dist/utils/text.d.ts +12 -0
  105. package/dist/utils/text.d.ts.map +1 -0
  106. package/dist/utils/text.js +46 -0
  107. package/dist/utils/text.js.map +1 -0
  108. package/dist/version.d.ts +3 -0
  109. package/dist/version.d.ts.map +1 -0
  110. package/dist/version.js +6 -0
  111. package/dist/version.js.map +1 -0
  112. package/package.json +86 -4
package/dist/client.js ADDED
@@ -0,0 +1,797 @@
1
+ import crypto from 'node:crypto';
2
+ import net from 'node:net';
3
+ import { VERSION } from './version.js';
4
+ const DEFAULT_BASE_URL = 'https://api.teamso.com/v1';
5
+ const DEFAULT_HELP_BASE_URL = 'https://teamso.com';
6
+ const DEFAULT_TIMEOUT_MS = 30_000;
7
+ const HELP_CACHE_TTL_MS = 10 * 60_000;
8
+ const HELP_RETRY_AFTER_MS = 60_000;
9
+ const ACCESS_TOKEN_PREFIX = 'tms_pat_';
10
+ const MIN_PROXY_SECRET_LENGTH = 32;
11
+ const LEAVE_ANSWER_PLACEHOLDER = '-';
12
+ export const CLIENT_HEADER = 'X-Teamso-Client';
13
+ export const PROXY_CLIENT_IP_HEADER = 'X-Teamso-Proxy-Client-Ip';
14
+ export const PROXY_TIMESTAMP_HEADER = 'X-Teamso-Proxy-Timestamp';
15
+ export const PROXY_SIGNATURE_HEADER = 'X-Teamso-Proxy-Signature';
16
+ /** With value `read`, the API lets the request reach only handlers that do not write (403 otherwise). */
17
+ export const MCP_INTENT_HEADER = 'X-Teamso-Mcp-Intent';
18
+ const CATALOG_PATH = '/mcp-catalog';
19
+ const ENDPOINT_CACHE_TTL_MS = 60_000;
20
+ const ENDPOINT_CACHE_MAX = 200;
21
+ const DEFAULT_MAX_RESPONSE_BYTES = 16 * 1024 * 1024;
22
+ /**
23
+ * `message_code` values that mean failure even when the API answers HTTP 200
24
+ * with `code: 1`. Anything ending in `_ERROR` or `NOT_FOUND` is a failure too.
25
+ */
26
+ const FAILURE_MESSAGE_CODES = new Set([
27
+ 'ACCOUNT_LOCKED',
28
+ 'ALREADY_ASSIGNED',
29
+ 'ALREADY_EXISTS',
30
+ 'AUTHENTICATION_FAILED',
31
+ 'CAPACITY_FULL',
32
+ 'CONTENT_TOO_LONG',
33
+ 'CREDIT_INSUFFICIENT',
34
+ 'DATE_RANGE_MISMATCH',
35
+ 'INSUFFICIENT_WALLET_BALANCE',
36
+ 'INVALID_CREDENTIALS',
37
+ 'INVALID_DATA',
38
+ 'INVALID_FORMAT',
39
+ 'INVALID_INPUT_PARAMETERS',
40
+ 'ITEM_EXIST',
41
+ 'LICENSE_EXPIRED',
42
+ 'LICENSE_EXPIRED_FOR_MANAGER',
43
+ 'MANDATORY_ENTER_DETAIL',
44
+ 'NO_RESPONSIBLE_POSITION',
45
+ 'NOT_REQUESTED',
46
+ 'NOT_SUITABLE_FOR_ADD',
47
+ 'OTP_NOT_DONE',
48
+ 'PERMISSION_DENIED',
49
+ 'RANGE_NOT_SUITABLE',
50
+ 'RECORD_BUSY',
51
+ 'STATUS_NOT_VALID',
52
+ 'SUPPORT_ACCESS_NOT_GRANTED',
53
+ 'TOKEN_INVALID',
54
+ 'USER_EXIST',
55
+ 'USER_IS_PASSIVE',
56
+ 'YOU_CAN_ADD_ONLY_YOURSELF',
57
+ 'YOU_CAN_DELETE_ONLY_YOURSELF',
58
+ ]);
59
+ const FAILURE_MESSAGE_CODE_PATTERN = /(^|_)(ERROR|NOT_FOUND)$/;
60
+ export function isFailureMessageCode(messageCode) {
61
+ if (!messageCode)
62
+ return false;
63
+ return FAILURE_MESSAGE_CODES.has(messageCode)
64
+ || FAILURE_MESSAGE_CODE_PATTERN.test(messageCode);
65
+ }
66
+ /**
67
+ * HMAC-SHA256 over `timestamp\nclientIp\nMETHOD\npathname\nsha256hex(token)`,
68
+ * lowercase hex. The API verifies the same message within ±60 seconds.
69
+ */
70
+ export function signProxyRequest(input) {
71
+ const tokenHash = crypto.createHash('sha256').update(input.accessToken).digest('hex');
72
+ const message = [
73
+ input.timestamp,
74
+ input.clientIp,
75
+ input.method.toUpperCase(),
76
+ input.pathname,
77
+ tokenHash,
78
+ ].join('\n');
79
+ return crypto
80
+ .createHmac('sha256', input.sharedSecret)
81
+ .update(message)
82
+ .digest('hex');
83
+ }
84
+ /**
85
+ * A failed Teamso API call. Teamso reports many failures with HTTP 200 and
86
+ * `code: 1`, so `status` alone does not tell success from failure: check
87
+ * `messageCode` (e.g. `OTP_NOT_DONE`, `ITEM_NOT_FOUND`, `PERMISSION_DENIED`).
88
+ */
89
+ export class TeamsoApiError extends Error {
90
+ status;
91
+ messageCode;
92
+ apiMessage;
93
+ body;
94
+ constructor(status, messageCode, message, body) {
95
+ const label = [`HTTP ${status}`, messageCode].filter(Boolean).join(', ');
96
+ super(`Teamso API error (${label})${message ? `: ${message}` : ''}`);
97
+ this.name = 'TeamsoApiError';
98
+ this.status = status;
99
+ this.messageCode = messageCode;
100
+ this.apiMessage = message;
101
+ this.body = body;
102
+ }
103
+ /** `result` of the response envelope, when the API sent one. */
104
+ get result() {
105
+ return isRecord(this.body) ? this.body.result : undefined;
106
+ }
107
+ }
108
+ /** A response body larger than the caller accepts; nothing of it is returned. */
109
+ export class TeamsoResponseTooLargeError extends Error {
110
+ limitBytes;
111
+ /** Size announced by `Content-Length`, when known. */
112
+ sizeBytes;
113
+ constructor(limitBytes, sizeBytes) {
114
+ super(`The Teamso response is larger than ${formatMegabytes(limitBytes)}`
115
+ + `${sizeBytes ? ` (${formatMegabytes(sizeBytes)})` : ''}.`);
116
+ this.name = 'TeamsoResponseTooLargeError';
117
+ this.limitBytes = limitBytes;
118
+ this.sizeBytes = sizeBytes;
119
+ }
120
+ }
121
+ export function createHelpCache() {
122
+ return new Map();
123
+ }
124
+ export class TeamsoClient {
125
+ baseUrl;
126
+ helpBaseUrl;
127
+ /** Origin of the help links and images in tool output. */
128
+ helpPublicUrl;
129
+ // ES private fields: unlike TypeScript `private`, they stay out of
130
+ // console.log, util.inspect and JSON.stringify, so the credentials never
131
+ // reach a log line through the client object.
132
+ #accessToken;
133
+ #trustedProxy;
134
+ fetchImpl;
135
+ timeoutMs;
136
+ helpCacheTtlMs;
137
+ now;
138
+ helpCache;
139
+ endpointCache = new Map();
140
+ constructor(accessToken, baseUrl, options = {}) {
141
+ if (!accessToken)
142
+ throw new Error('TEAMSO_ACCESS_TOKEN is required');
143
+ if (!accessToken.startsWith(ACCESS_TOKEN_PREFIX)) {
144
+ throw new Error(`TEAMSO_ACCESS_TOKEN must be a Teamso personal access token (${ACCESS_TOKEN_PREFIX}...)`);
145
+ }
146
+ let trustedProxy;
147
+ if (options.trustedProxy) {
148
+ const clientIp = normalizeClientIp(options.trustedProxy.clientIp ?? '');
149
+ if (net.isIP(clientIp) === 0) {
150
+ throw new Error('trustedProxy.clientIp must be a valid IPv4 or IPv6 address');
151
+ }
152
+ if ((options.trustedProxy.sharedSecret ?? '').length < MIN_PROXY_SECRET_LENGTH) {
153
+ throw new Error(`trustedProxy.sharedSecret must be at least ${MIN_PROXY_SECRET_LENGTH} characters`);
154
+ }
155
+ trustedProxy = { clientIp, sharedSecret: options.trustedProxy.sharedSecret };
156
+ }
157
+ this.#accessToken = accessToken;
158
+ this.baseUrl = trimTrailingSlashes(baseUrl || DEFAULT_BASE_URL);
159
+ this.helpBaseUrl = trimTrailingSlashes(options.helpBaseUrl || DEFAULT_HELP_BASE_URL);
160
+ this.helpPublicUrl = trimTrailingSlashes(options.helpPublicUrl || this.helpBaseUrl);
161
+ this.#trustedProxy = trustedProxy;
162
+ this.fetchImpl = options.fetch ?? ((input, init) => fetch(input, init));
163
+ this.timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
164
+ this.helpCacheTtlMs = options.helpCacheTtlMs ?? HELP_CACHE_TTL_MS;
165
+ this.now = options.now ?? Date.now;
166
+ this.helpCache = options.helpCache ?? createHelpCache();
167
+ }
168
+ // ---------------------------------------------------------------- account
169
+ async getMe(signal) {
170
+ return this.call('GET', '/user', { signal });
171
+ }
172
+ // ------------------------------------------------------------------ leave
173
+ async listLeavePolicies(signal) {
174
+ return asArray(await this.call('GET', '/leave-policy/getListAllLeavePolicyForUser', { signal }));
175
+ }
176
+ async getLeavePolicyBalances(leavePolicyUserId, signal) {
177
+ return asArray(await this.call('GET', `/leave-policy/getDetailLeavePolicyForUser/${segment(leavePolicyUserId)}`, { signal }));
178
+ }
179
+ async getMyWorkingDays(signal) {
180
+ return this.call('GET', '/working-days/getWorkingDaysByMe', { signal });
181
+ }
182
+ async getMyPublicHolidays(signal) {
183
+ return this.call('GET', '/public-holiday/getPublicHolidayByMe', { signal });
184
+ }
185
+ /** Dates are `yyyy-MM-dd HH:mm` Turkey wall clock. */
186
+ async calculateLeaveDays(params, signal) {
187
+ const path = [
188
+ '/leave/dayCalculator',
189
+ segment(params.workingDaysId),
190
+ segment(params.publicHolidayId),
191
+ segment(params.leaveTypeId),
192
+ segment(params.startDate),
193
+ segment(params.endDate),
194
+ ].join('/');
195
+ return this.call('GET', path, { signal });
196
+ }
197
+ async checkLeaveRequest(params, signal) {
198
+ return this.call('POST', '/leave/controlToAddLeave', {
199
+ query: {
200
+ leaveTypeId: params.leaveTypeId,
201
+ addedDay: params.addedDay,
202
+ startDate: params.startDate,
203
+ endDate: params.endDate,
204
+ leavePolicyUserId: params.leavePolicyUserId,
205
+ },
206
+ body: [params.userId],
207
+ signal,
208
+ });
209
+ }
210
+ async createLeave(request, signal) {
211
+ return this.requestEnvelope('POST', '/leave', { body: request, signal });
212
+ }
213
+ async listLeaves(params = {}, signal) {
214
+ return this.call('GET', '/leave/listAllLeavesAdvancedPagination', {
215
+ query: {
216
+ page: params.page ?? 0,
217
+ size: params.size ?? 50,
218
+ sortKey: 'createdAt',
219
+ sortDirection: 'desc',
220
+ searchKey: params.searchKey,
221
+ searchValue: params.searchValue,
222
+ rangeStart: params.rangeStart,
223
+ rangeEnd: params.rangeEnd,
224
+ },
225
+ signal,
226
+ });
227
+ }
228
+ async deleteLeave(leaveId, signal) {
229
+ return this.requestEnvelope('DELETE', `/leave/${segment(leaveId)}`, { signal });
230
+ }
231
+ /**
232
+ * Records an approval decision. `answer` is stored as the decision note (the
233
+ * rejection reason); the API requires one, so approvals without a note send `-`.
234
+ */
235
+ async respondToLeave(leaveId, approve, answer, signal) {
236
+ const note = pathSafeText(answer ?? '') || LEAVE_ANSWER_PLACEHOLDER;
237
+ const path = `/leave/state/${segment(leaveId)}/${approve}/${segment(note)}`;
238
+ return this.requestEnvelope('PUT', path, { signal });
239
+ }
240
+ // -------------------------------------------------------------------- otp
241
+ /** Sends a 6-digit SMS code to the caller's phone. Costs SMS credit. */
242
+ async requestOtp(signal) {
243
+ return this.requestEnvelope('GET', '/sms/generateToken', {
244
+ query: { checkCredit: true },
245
+ signal,
246
+ });
247
+ }
248
+ /** Verifies the SMS code; the next OTP-protected call consumes the verification. */
249
+ async verifyOtp(code, signal) {
250
+ return this.requestEnvelope('GET', `/sms/checkToken/${segment(code)}`, { signal });
251
+ }
252
+ // -------------------------------------------------------------- approvals
253
+ async listPendingTransactions(signal) {
254
+ return asArray(await this.call('GET', '/dashboard/getPendingTransactionsByRequestOwner', { signal }));
255
+ }
256
+ // -------------------------------------------------------------- employees
257
+ async searchUsers(params, signal) {
258
+ return this.call('GET', '/user/list-pagination', {
259
+ query: {
260
+ page: params.page ?? 0,
261
+ size: params.size ?? 50,
262
+ activeFilter: true,
263
+ searchKey: 'user',
264
+ searchValue: params.query,
265
+ },
266
+ signal,
267
+ });
268
+ }
269
+ async listAuthorizedEmployees(signal) {
270
+ return asArray(await this.call('GET', '/authorized-employee/list', { signal }));
271
+ }
272
+ async getUserSummary(userId, signal) {
273
+ return this.call('GET', `/user/getUserSummaryByUserId/${segment(userId)}`, { signal });
274
+ }
275
+ // ------------------------------------------------------------ performance
276
+ async getPerformanceSettings(signal) {
277
+ return this.call('GET', '/performance-settings', { signal });
278
+ }
279
+ async listMyGoals(params, signal) {
280
+ return this.call('GET', '/user-goal/my', {
281
+ query: { periodName: params.periodName, page: params.page ?? 0, size: params.size ?? 100 },
282
+ signal,
283
+ });
284
+ }
285
+ async listAssignedGoals(params, signal) {
286
+ return this.call('GET', '/user-goal/assigned', {
287
+ query: {
288
+ periodName: params.periodName,
289
+ status: params.status,
290
+ page: params.page ?? 0,
291
+ size: params.size ?? 100,
292
+ },
293
+ signal,
294
+ });
295
+ }
296
+ async getGoal(goalId, signal) {
297
+ return this.call('GET', `/user-goal/${segment(goalId)}`, { signal });
298
+ }
299
+ async createGoal(request, signal) {
300
+ return this.call('POST', '/user-goal', { body: request, signal });
301
+ }
302
+ async sendGoalsToApproval(goalIds, signal) {
303
+ return this.requestEnvelope('POST', '/user-goal/send-to-approval', { body: goalIds, signal });
304
+ }
305
+ /** `GET` on the API, but it changes the goal status. */
306
+ async approveGoal(goalId, signal) {
307
+ return this.requestEnvelope('GET', `/user-goal/approve/${segment(goalId)}`, { signal });
308
+ }
309
+ async reviseAndApproveGoal(goalId, request, signal) {
310
+ return this.requestEnvelope('PUT', `/user-goal/revise-and-approve/${segment(goalId)}`, {
311
+ body: request,
312
+ signal,
313
+ });
314
+ }
315
+ async updateGoalActualValue(goalId, params, signal) {
316
+ return this.requestEnvelope('PUT', `/user-goal/${segment(goalId)}/actual-value`, {
317
+ query: { actualValue: params.actualValue, actualText: params.actualText },
318
+ signal,
319
+ });
320
+ }
321
+ async updateManagerActualValue(goalId, params, signal) {
322
+ return this.requestEnvelope('PUT', `/user-goal/${segment(goalId)}/manager-actual-value`, {
323
+ query: {
324
+ managerActualValue: params.managerActualValue,
325
+ managerActualText: params.managerActualText,
326
+ },
327
+ signal,
328
+ });
329
+ }
330
+ async approveEvaluationProcess(params, signal) {
331
+ return this.requestEnvelope('POST', '/user-goal/approve-evaluation-process', {
332
+ query: { periodName: params.periodName, userId: params.userId },
333
+ signal,
334
+ });
335
+ }
336
+ // ---------------------------------------------------------- communication
337
+ async getAnnouncements(signal) {
338
+ return this.call('GET', '/dashboard/getAnnouncements', { signal });
339
+ }
340
+ // ---------------------------------------------------------------- payroll
341
+ async listMyPayslips(params = {}, signal) {
342
+ return this.call('GET', '/payroll/getPayrollsByMe-pagination', {
343
+ query: {
344
+ page: params.page ?? 0,
345
+ size: params.size ?? 12,
346
+ sortKey: 'term',
347
+ sortDirection: 'desc',
348
+ searchKey: params.searchKey,
349
+ searchValue: params.searchValue,
350
+ },
351
+ signal,
352
+ });
353
+ }
354
+ /** Raw PDF bytes. The API answers every failure with a bodyless 404. */
355
+ async downloadPayslip(payslipId, signal) {
356
+ const response = await this.send('GET', `/payroll/download-file/${segment(payslipId)}`, {
357
+ signal,
358
+ accept: 'application/pdf, application/json;q=0.5',
359
+ });
360
+ const contentType = response.headers.get('content-type') ?? 'application/octet-stream';
361
+ if (!response.ok || contentType.includes('json')) {
362
+ const text = await response.text();
363
+ const parsed = parseJson(text);
364
+ if (!response.ok)
365
+ throw errorFromResponse(response.status, parsed, text);
366
+ throw envelopeError(response.status, parsed);
367
+ }
368
+ return {
369
+ data: new Uint8Array(await response.arrayBuffer()),
370
+ contentType: contentType.split(';')[0].trim(),
371
+ fileName: fileNameFromDisposition(response.headers.get('content-disposition')),
372
+ };
373
+ }
374
+ // ------------------------------------------------------------ API catalog
375
+ /** Endpoints the caller may call (`GET /mcp-catalog/endpoints`); pages start at 1. */
376
+ async listApiEndpoints(params = {}, signal) {
377
+ const page = await this.call('GET', `${CATALOG_PATH}/endpoints`, {
378
+ query: {
379
+ q: params.q,
380
+ module: params.module,
381
+ method: params.method,
382
+ page: params.page,
383
+ size: params.size,
384
+ lang: params.lang,
385
+ },
386
+ signal,
387
+ });
388
+ return isRecord(page) ? page : {};
389
+ }
390
+ /**
391
+ * Full description of one endpoint the caller may call (`GET /mcp-catalog/endpoint`). Unknown,
392
+ * hidden and not-allowed ids are `ITEM_NOT_FOUND`. Descriptions are cached for a minute per client.
393
+ */
394
+ async getApiEndpoint(id, lang, signal) {
395
+ const key = `${lang ?? ''}\n${id}`;
396
+ const now = this.now();
397
+ const cached = this.endpointCache.get(key);
398
+ if (cached && now < cached.expiresAt)
399
+ return cached.value;
400
+ const description = await this.call('GET', `${CATALOG_PATH}/endpoint`, {
401
+ query: { id, lang },
402
+ signal,
403
+ });
404
+ if (!isRecord(description)) {
405
+ throw new TeamsoApiError(200, 'ITEM_NOT_FOUND', 'The catalog returned no description', description);
406
+ }
407
+ if (this.endpointCache.size >= ENDPOINT_CACHE_MAX) {
408
+ const oldest = this.endpointCache.keys().next().value;
409
+ if (oldest !== undefined)
410
+ this.endpointCache.delete(oldest);
411
+ }
412
+ this.endpointCache.set(key, { value: description, expiresAt: now + ENDPOINT_CACHE_TTL_MS });
413
+ return description;
414
+ }
415
+ /** Product modules with endpoint counts (`GET /mcp-catalog/modules`). */
416
+ async listApiModules(lang, signal) {
417
+ return asArray(await this.call('GET', `${CATALOG_PATH}/modules`, { query: { lang }, signal }));
418
+ }
419
+ /**
420
+ * Calls one catalog endpoint. JSON answers are unwrapped (Teamso failures, including HTTP 200
421
+ * failure codes, throw `TeamsoApiError`); anything else comes back as a file. Bodies larger than
422
+ * `maxResponseBytes` throw `TeamsoResponseTooLargeError` without being buffered.
423
+ */
424
+ async callApi(request, signal) {
425
+ assertApiPath(request.path);
426
+ const options = {
427
+ query: request.query,
428
+ signal,
429
+ accept: 'application/json, */*;q=0.8',
430
+ headers: request.readIntent ? { [MCP_INTENT_HEADER]: 'read' } : undefined,
431
+ // A redirect would leave the catalog route: fetch would follow it to any host (internal
432
+ // addresses included) and hand that answer to the assistant as a Teamso file.
433
+ redirect: 'manual',
434
+ };
435
+ const body = request.body;
436
+ if (body?.kind === 'json') {
437
+ options.body = body.value;
438
+ }
439
+ else if (body?.kind === 'text') {
440
+ options.rawBody = body.value;
441
+ options.contentType = body.contentType;
442
+ }
443
+ else if (body?.kind === 'multipart') {
444
+ const form = new FormData();
445
+ for (const [name, values] of Object.entries(body.fields)) {
446
+ for (const value of values)
447
+ form.append(name, value);
448
+ }
449
+ for (const file of body.files) {
450
+ form.append(file.field, new Blob([file.data], { type: file.contentType }), file.filename);
451
+ }
452
+ options.rawBody = form;
453
+ }
454
+ const response = await this.send(request.method, request.path, options);
455
+ if (response.type === 'opaqueredirect' || (response.status >= 300 && response.status < 400)) {
456
+ await response.body?.cancel().catch(() => undefined);
457
+ throw new TeamsoApiError(response.status || 302, 'REDIRECT_NOT_FOLLOWED', 'Teamso answered with a redirect; generic API calls do not follow redirects', undefined);
458
+ }
459
+ const contentType = response.headers.get('content-type') ?? '';
460
+ const data = await readLimited(response, request.maxResponseBytes ?? DEFAULT_MAX_RESPONSE_BYTES);
461
+ const isJson = /json/i.test(contentType) || (contentType === '' && looksLikeJson(data));
462
+ if (!response.ok) {
463
+ const text = new TextDecoder().decode(data);
464
+ throw errorFromResponse(response.status, parseJson(text), text);
465
+ }
466
+ if (!isJson) {
467
+ const fileName = fileNameFromDisposition(response.headers.get('content-disposition'));
468
+ return {
469
+ kind: 'file',
470
+ status: response.status,
471
+ data,
472
+ contentType: fileMimeType(contentType.split(';')[0].trim(), fileName),
473
+ fileName,
474
+ };
475
+ }
476
+ const text = new TextDecoder().decode(data);
477
+ const parsed = parseJson(text);
478
+ if (text.trim() !== '' && parsed === undefined) {
479
+ throw new TeamsoApiError(response.status, undefined, 'The API returned a response that is not valid JSON', text.slice(0, 300));
480
+ }
481
+ if (isRecord(parsed) && (typeof parsed.message_code === 'string' || typeof parsed.code === 'number')) {
482
+ const envelope = parsed;
483
+ if (envelope.code === 0 || isFailureMessageCode(envelope.message_code)) {
484
+ throw envelopeError(response.status, envelope);
485
+ }
486
+ return {
487
+ kind: 'json',
488
+ status: response.status,
489
+ envelope: true,
490
+ messageCode: stringOrUndefined(envelope.message_code),
491
+ message: stringOrUndefined(envelope.message),
492
+ result: envelope.result,
493
+ };
494
+ }
495
+ return { kind: 'json', status: response.status, envelope: false, result: parsed };
496
+ }
497
+ // ------------------------------------------------------------------- help
498
+ /**
499
+ * Public help-center bundle. Cached in memory; when a refresh fails the last
500
+ * good copy is served and the download is retried a minute later.
501
+ */
502
+ async getHelpBundle(lang) {
503
+ const now = this.now();
504
+ const key = `${this.helpBaseUrl}\n${lang}`;
505
+ const entry = this.helpCache.get(key);
506
+ if (entry?.bundle && now < entry.expiresAt)
507
+ return entry.bundle;
508
+ if (entry?.pending)
509
+ return entry.pending;
510
+ // One shared download per language; a caller's abort must not fail the others.
511
+ const pending = this.downloadHelpBundle(lang)
512
+ .then(bundle => {
513
+ this.helpCache.set(key, { bundle, expiresAt: this.now() + this.helpCacheTtlMs });
514
+ return bundle;
515
+ })
516
+ .catch((error) => {
517
+ const stale = this.helpCache.get(key)?.bundle;
518
+ this.helpCache.set(key, { bundle: stale, expiresAt: this.now() + HELP_RETRY_AFTER_MS });
519
+ if (stale)
520
+ return stale;
521
+ throw error;
522
+ });
523
+ this.helpCache.set(key, { bundle: entry?.bundle, expiresAt: entry?.expiresAt ?? 0, pending });
524
+ return pending;
525
+ }
526
+ async downloadHelpBundle(lang) {
527
+ const url = `${this.helpBaseUrl}/how-to/bundle.${lang}.json`;
528
+ const response = await this.fetchImpl(url, {
529
+ headers: { Accept: 'application/json', [CLIENT_HEADER]: `teamso-mcp/${VERSION}` },
530
+ signal: withTimeout(undefined, this.timeoutMs),
531
+ });
532
+ if (!response.ok) {
533
+ throw new Error(`Help articles could not be downloaded from ${url} (HTTP ${response.status})`);
534
+ }
535
+ const parsed = parseJson(await response.text());
536
+ if (!isRecord(parsed) || !Array.isArray(parsed.articles) || !Array.isArray(parsed.modules)) {
537
+ throw new Error(`Help bundle at ${url} is not in the expected format`);
538
+ }
539
+ return parsed;
540
+ }
541
+ // -------------------------------------------------------------- transport
542
+ async call(method, path, options = {}) {
543
+ const envelope = await this.requestEnvelope(method, path, options);
544
+ return envelope.result;
545
+ }
546
+ async requestEnvelope(method, path, options = {}) {
547
+ const response = await this.send(method, path, options);
548
+ const text = await response.text();
549
+ const parsed = parseJson(text);
550
+ if (!response.ok)
551
+ throw errorFromResponse(response.status, parsed, text);
552
+ if (!isRecord(parsed)) {
553
+ throw new TeamsoApiError(response.status, undefined, 'The API returned a response that is not JSON', text);
554
+ }
555
+ const envelope = parsed;
556
+ if (envelope.code === 0 || isFailureMessageCode(envelope.message_code)) {
557
+ throw envelopeError(response.status, envelope);
558
+ }
559
+ return envelope;
560
+ }
561
+ async send(method, path, options) {
562
+ const url = new URL(`${this.baseUrl}${path}`);
563
+ for (const [key, value] of Object.entries(options.query ?? {})) {
564
+ if (value === undefined || value === null || value === '')
565
+ continue;
566
+ if (Array.isArray(value)) {
567
+ for (const item of value)
568
+ url.searchParams.append(key, String(item));
569
+ }
570
+ else {
571
+ url.searchParams.set(key, String(value));
572
+ }
573
+ }
574
+ const headers = {
575
+ ...options.headers,
576
+ Authorization: `Bearer ${this.#accessToken}`,
577
+ Accept: options.accept ?? 'application/json',
578
+ [CLIENT_HEADER]: `teamso-mcp/${VERSION}`,
579
+ };
580
+ if (options.body !== undefined)
581
+ headers['Content-Type'] = 'application/json';
582
+ else if (typeof options.rawBody === 'string')
583
+ headers['Content-Type'] = options.contentType ?? 'text/plain';
584
+ const proxy = this.#trustedProxy;
585
+ if (proxy) {
586
+ const timestamp = String(this.now());
587
+ headers[PROXY_CLIENT_IP_HEADER] = proxy.clientIp;
588
+ headers[PROXY_TIMESTAMP_HEADER] = timestamp;
589
+ headers[PROXY_SIGNATURE_HEADER] = signProxyRequest({
590
+ sharedSecret: proxy.sharedSecret,
591
+ timestamp,
592
+ clientIp: proxy.clientIp,
593
+ method,
594
+ pathname: url.pathname,
595
+ accessToken: this.#accessToken,
596
+ });
597
+ }
598
+ try {
599
+ return await this.fetchImpl(url, {
600
+ method,
601
+ headers,
602
+ body: options.body === undefined ? options.rawBody : JSON.stringify(options.body),
603
+ signal: withTimeout(options.signal, this.timeoutMs),
604
+ ...(options.redirect ? { redirect: options.redirect } : {}),
605
+ });
606
+ }
607
+ catch (error) {
608
+ throw networkError(error, this.baseUrl);
609
+ }
610
+ }
611
+ }
612
+ /**
613
+ * A request that never got an HTTP answer. Timeouts and cancellations pass
614
+ * through unchanged; connection failures name the API base URL (never the
615
+ * request URL, which may carry user input) so the user can check the setting.
616
+ */
617
+ function networkError(error, baseUrl) {
618
+ if (!(error instanceof Error) || error.name === 'TimeoutError' || error.name === 'AbortError')
619
+ return error;
620
+ const reason = error.cause instanceof Error && error.cause.message ? ` (${error.cause.message})` : '';
621
+ return new Error(`Could not reach the Teamso API at ${baseUrl}${reason}. `
622
+ + 'Check the network connection and `TEAMSO_API_BASE_URL`.', { cause: error });
623
+ }
624
+ /** `::ffff:203.0.113.7` → `203.0.113.7`, so the API matches IPv4 allowlist entries. */
625
+ function normalizeClientIp(value) {
626
+ const trimmed = value.trim();
627
+ const mapped = /^::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/i.exec(trimmed);
628
+ return mapped?.[1] ?? trimmed;
629
+ }
630
+ function envelopeError(status, body) {
631
+ const envelope = isRecord(body) ? body : {};
632
+ const errorText = (Array.isArray(envelope.errors) ? envelope.errors : [])
633
+ .map(error => (typeof error === 'string' ? error : error?.message))
634
+ .filter(Boolean)
635
+ .join(', ');
636
+ const message = envelope.message || errorText || envelope.message_code || 'The request failed';
637
+ return new TeamsoApiError(status, envelope.message_code, message, body);
638
+ }
639
+ function errorFromResponse(status, parsed, text) {
640
+ if (isRecord(parsed)) {
641
+ const apiError = isRecord(parsed.apierror) ? parsed.apierror : undefined;
642
+ const messageCode = stringOrUndefined(parsed.message_code) ?? stringOrUndefined(parsed.error);
643
+ const message = stringOrUndefined(parsed.message)
644
+ ?? stringOrUndefined(apiError?.message)
645
+ ?? stringOrUndefined(apiError?.debugMessage);
646
+ return new TeamsoApiError(status, messageCode, message, parsed);
647
+ }
648
+ const snippet = text.trim().slice(0, 300);
649
+ return new TeamsoApiError(status, undefined, snippet || undefined, text);
650
+ }
651
+ function withTimeout(signal, timeoutMs) {
652
+ const timeout = AbortSignal.timeout(timeoutMs);
653
+ if (!signal)
654
+ return timeout;
655
+ if (typeof AbortSignal.any === 'function')
656
+ return AbortSignal.any([signal, timeout]);
657
+ const controller = new AbortController();
658
+ const abort = (source) => controller.abort(source.reason);
659
+ for (const source of [signal, timeout]) {
660
+ if (source.aborted) {
661
+ abort(source);
662
+ break;
663
+ }
664
+ source.addEventListener('abort', () => abort(source), { once: true });
665
+ }
666
+ return controller.signal;
667
+ }
668
+ const MIME_BY_EXTENSION = {
669
+ xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
670
+ xls: 'application/vnd.ms-excel',
671
+ docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
672
+ pdf: 'application/pdf',
673
+ csv: 'text/csv',
674
+ zip: 'application/zip',
675
+ xml: 'application/xml',
676
+ txt: 'text/plain',
677
+ png: 'image/png',
678
+ jpg: 'image/jpeg',
679
+ jpeg: 'image/jpeg',
680
+ webp: 'image/webp',
681
+ };
682
+ /**
683
+ * Many Teamso exports answer `application/octet-stream`; the file name then tells what it is, so MCP
684
+ * clients can open an Excel or PDF instead of an unknown binary. A specific type from the API wins.
685
+ */
686
+ export function fileMimeType(contentType, fileName) {
687
+ const generic = contentType === '' || /^(application|binary)\/octet-stream$/i.test(contentType);
688
+ if (!generic)
689
+ return contentType;
690
+ const extension = fileName?.match(/\.([a-z0-9]{2,5})$/i)?.[1]?.toLowerCase();
691
+ return (extension && MIME_BY_EXTENSION[extension]) || 'application/octet-stream';
692
+ }
693
+ function fileNameFromDisposition(header) {
694
+ if (!header)
695
+ return undefined;
696
+ const encoded = /filename\*=UTF-8''([^;]+)/i.exec(header);
697
+ if (encoded?.[1]) {
698
+ try {
699
+ return decodeURIComponent(encoded[1].trim());
700
+ }
701
+ catch {
702
+ // Fall through to the plain filename parameter.
703
+ }
704
+ }
705
+ const plain = /filename="?([^";]+)"?/i.exec(header);
706
+ return plain?.[1]?.trim() || undefined;
707
+ }
708
+ /**
709
+ * Free text that travels as a URL path segment. Tomcat and Spring Security
710
+ * reject encoded slashes, backslashes, semicolons and percent signs there, and
711
+ * a segment of only dots is path traversal, so those are replaced.
712
+ */
713
+ export function pathSafeText(value) {
714
+ const cleaned = value.replace(/[/\\;%]+/g, ' ').replace(/\s+/g, ' ').trim();
715
+ return /^\.+$/.test(cleaned) ? '' : cleaned;
716
+ }
717
+ /** Encodes one path segment; `:` stays literal like browsers send it (`2026-11-02%2009:00`). */
718
+ function segment(value) {
719
+ return encodeURIComponent(value).replace(/%3A/gi, ':');
720
+ }
721
+ /**
722
+ * Generic calls only go to resolved catalog paths: absolute, no query or fragment, no empty or dot
723
+ * segments, so a crafted path can never leave the API base URL or reach another route. A single
724
+ * trailing slash is kept: some Teamso routes are mapped with one (e.g. `GET /dashboard-shortcut/`)
725
+ * and do not answer without it.
726
+ */
727
+ function assertApiPath(path) {
728
+ if (!path.startsWith('/') || /[?#\\\s]/.test(path))
729
+ throw new Error(`Refusing to call an invalid API path: ${path}`);
730
+ const segments = path.slice(1).split('/');
731
+ if (segments.length > 1 && segments[segments.length - 1] === '')
732
+ segments.pop();
733
+ if (segments.some(part => part === '' || /^(\.|%2e)+$/i.test(part))) {
734
+ throw new Error(`Refusing to call an invalid API path: ${path}`);
735
+ }
736
+ }
737
+ /** Reads the body up to `limit` bytes; stops the download and throws as soon as it is exceeded. */
738
+ async function readLimited(response, limit) {
739
+ const announced = Number(response.headers.get('content-length'));
740
+ if (Number.isFinite(announced) && announced > limit) {
741
+ await response.body?.cancel().catch(() => undefined);
742
+ throw new TeamsoResponseTooLargeError(limit, announced);
743
+ }
744
+ if (!response.body)
745
+ return new Uint8Array(await response.arrayBuffer());
746
+ const reader = response.body.getReader();
747
+ const chunks = [];
748
+ let total = 0;
749
+ for (;;) {
750
+ const { done, value } = await reader.read();
751
+ if (done)
752
+ break;
753
+ total += value.byteLength;
754
+ if (total > limit) {
755
+ await reader.cancel().catch(() => undefined);
756
+ throw new TeamsoResponseTooLargeError(limit);
757
+ }
758
+ chunks.push(value);
759
+ }
760
+ const data = new Uint8Array(total);
761
+ let offset = 0;
762
+ for (const chunk of chunks) {
763
+ data.set(chunk, offset);
764
+ offset += chunk.byteLength;
765
+ }
766
+ return data;
767
+ }
768
+ function looksLikeJson(data) {
769
+ const head = new TextDecoder().decode(data.subarray(0, 64)).trimStart();
770
+ return head.startsWith('{') || head.startsWith('[');
771
+ }
772
+ function formatMegabytes(bytes) {
773
+ return `${Math.round((bytes / (1024 * 1024)) * 10) / 10} MB`;
774
+ }
775
+ function trimTrailingSlashes(value) {
776
+ return value.replace(/\/+$/, '');
777
+ }
778
+ function parseJson(text) {
779
+ if (!text)
780
+ return undefined;
781
+ try {
782
+ return JSON.parse(text);
783
+ }
784
+ catch {
785
+ return undefined;
786
+ }
787
+ }
788
+ function asArray(value) {
789
+ return Array.isArray(value) ? value : [];
790
+ }
791
+ function stringOrUndefined(value) {
792
+ return typeof value === 'string' && value.length > 0 ? value : undefined;
793
+ }
794
+ function isRecord(value) {
795
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
796
+ }
797
+ //# sourceMappingURL=client.js.map