@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
@@ -0,0 +1,980 @@
1
+ import { z } from 'zod';
2
+ import { TeamsoApiError, TeamsoResponseTooLargeError } from '../client.js';
3
+ import { HTTP_METHODS, SIDE_EFFECTS, checkQuery, displayPath, exampleFromSchema, isBinarySchema, isRecord, pathVariables, resolvePath, safeStringify, shapeResult, splitEndpointId, toApiEndpointDetails, toApiEndpointSummary, toApiModule, validateBody, } from '../utils/api-endpoints.js';
4
+ import { escapeCell, markdownTable } from '../utils/format.js';
5
+ import { ToolInputError, apiErrorResult, errorResult, toolResult, } from '../utils/results.js';
6
+ import { apiEndpointDescriptionOutputSchema, apiEndpointListOutputSchema, apiModulesOutputSchema, apiReadOutputSchema, apiWriteOutputSchema, otpOutputSchema, } from './schemas.js';
7
+ import { ADDITIVE_WRITE, DESTRUCTIVE_WRITE, READ_ONLY, otpCodeSchema } from './shared.js';
8
+ /** Largest file returned as an embedded resource. */
9
+ export const MAX_FILE_BYTES = 8 * 1024 * 1024;
10
+ /** Largest JSON response read from the API (it is then shortened to `MAX_RESULT_CHARS`). */
11
+ const MAX_JSON_RESPONSE_BYTES = 16 * 1024 * 1024;
12
+ /** Largest total of uploaded files per call (decoded). */
13
+ export const MAX_UPLOAD_BYTES = 10 * 1024 * 1024;
14
+ const MAX_JSON_BODY_CHARS = 2 * 1024 * 1024;
15
+ const MAX_UPLOAD_FILES = 10;
16
+ const MAX_PREVIEW_BODY_CHARS = 20_000;
17
+ const MAX_TEXT_SCHEMA_CHARS = 6_000;
18
+ const SIDE_EFFECT_SCAN_PAGE_SIZE = 100;
19
+ const SIDE_EFFECT_SCAN_MAX_PAGES = 30;
20
+ const OTP_VALIDITY_SECONDS = 180;
21
+ // ------------------------------------------------------------------ inputs
22
+ const langSchema = z.enum(['tr', 'en']).optional()
23
+ .describe('Language of module titles and summaries: `tr` (default) or `en`.');
24
+ const endpointIdSchema = z.string()
25
+ .trim()
26
+ .min(3)
27
+ .max(300)
28
+ .regex(/^(GET|POST|PUT|PATCH|DELETE)\s+\/\S*$/i, 'id must look like "METHOD /path", e.g. "GET /leave/{id}"')
29
+ .describe('Endpoint id exactly as `find_api_endpoints` returns it: `"METHOD /path/{placeholder}"`, '
30
+ + 'e.g. `"GET /expense-form/{id}"`. Placeholders stay in the id; their values go in `path_params`.');
31
+ const pathParamsSchema = z.record(z.string().min(1).max(100), z.union([z.string().max(500), z.number()])).optional()
32
+ .describe('Values for the `{placeholders}` of the path, e.g. `{"id": "8f1c..."}`.');
33
+ const queryValueSchema = z.union([z.string().max(2_000), z.number(), z.boolean()]);
34
+ const querySchema = z.record(z.string().min(1).max(100), z.union([queryValueSchema, z.array(queryValueSchema).max(100)])).optional()
35
+ .describe('Query parameters by name (see `describe_api_endpoint`); arrays repeat the parameter.');
36
+ const filesSchema = z.array(z.object({
37
+ field: z.string().trim().min(1).max(100).describe('Multipart field name from the endpoint\'s body schema (e.g. `file`).'),
38
+ filename: z.string().trim().min(1).max(200).describe('File name, e.g. `rapor.pdf`.'),
39
+ content_type: z.string().trim().regex(/^[\w.+-]+\/[\w.+-]+$/, 'content_type must be a MIME type like application/pdf')
40
+ .default('application/octet-stream')
41
+ .describe('MIME type, e.g. `application/pdf` or `image/png`.'),
42
+ base64: z.string().min(1).max(14_500_000).describe('File content, base64 encoded.'),
43
+ })).max(MAX_UPLOAD_FILES).optional()
44
+ .describe(`Files for multipart endpoints (\`multipart: true\`), at most ${MAX_UPLOAD_FILES} files and 10 MB in total.`);
45
+ // ------------------------------------------------------------------- hints
46
+ const CATALOG_HINTS = {
47
+ notFound: 'Teamso has no endpoint with that id that your account can call: it does not exist, is hidden from '
48
+ + 'MCP, or needs a permission you do not have. Find the right id with `find_api_endpoints` '
49
+ + '(ids look like "GET /leave/{id}"; put placeholder values in `path_params`).',
50
+ };
51
+ const OTP_FLOW = 'Call `request_sms_code`, ask the user for the code from the SMS, then call `call_api_write` again '
52
+ + 'with the same arguments, `confirm: true` and `otp_code` (codes are valid for 3 minutes).';
53
+ const CALL_HINTS = {
54
+ notFound: 'Teamso could not find that record, or it is not visible to you. Check the `path_params` and `query` '
55
+ + 'values (ids come from list/search endpoints).',
56
+ byCode: {
57
+ OTP_NOT_DONE: `This action needs an SMS verification code (missing, expired or already used). ${OTP_FLOW}`,
58
+ 'validation error.The token is not correct.': 'The SMS code is wrong. Ask the user to check it, or call '
59
+ + '`request_sms_code` for a new one.',
60
+ 'validation error.token is expired.': 'The SMS code has expired (codes are valid for 3 minutes). Call '
61
+ + '`request_sms_code` for a new one.',
62
+ 'Token Not Found': 'No SMS code is waiting for verification. Call `request_sms_code` first.',
63
+ 'validation error.too many attempts.': 'Too many wrong SMS codes: this code is cancelled. Call '
64
+ + '`request_sms_code` for a new one and ask the user for it.',
65
+ REDIRECT_NOT_FOLLOWED: 'Teamso answered this endpoint with a redirect, which MCP does not follow. Use the Teamso '
66
+ + 'app for this action.',
67
+ CREDIT_INSUFFICIENT: 'Your company does not have enough digital (SMS) credit for this action. The SMS code was '
68
+ + 'used up, so a retry after topping up needs a new code (`request_sms_code`).',
69
+ },
70
+ };
71
+ // ------------------------------------------------------------------- tools
72
+ export function registerFindApiEndpoints(server, client) {
73
+ server.registerTool('find_api_endpoints', {
74
+ title: 'Find Teamso API endpoints',
75
+ description: 'Search the Teamso API endpoints the caller is allowed to use (every module: leave, expenses, '
76
+ + 'payroll, time tracking, recruiting, inventory, settings...). Use it when no specific Teamso tool fits. '
77
+ + 'Each result has an `id` ("METHOD /path"), a summary, its side effect (`read`, `write`, `delete`, '
78
+ + '`read_with_writes`) and whether it needs an SMS code or returns a file. Then call '
79
+ + '`describe_api_endpoint`. Every word of `query` must match (Turkish-insensitive), so use 1-3 keywords.',
80
+ inputSchema: {
81
+ query: z.string().trim().max(200).optional()
82
+ .describe('Keywords matched against path, summary, module and screen names, e.g. "masraf" or "expense form".'),
83
+ module: z.string().trim().min(1).max(60).optional()
84
+ .describe('Module code from `list_api_modules`, e.g. `LEAVE`, `EXPENSE`, `PAY_SLIP`.'),
85
+ method: z.enum(HTTP_METHODS).optional().describe('Only this HTTP method.'),
86
+ side_effect: z.enum(SIDE_EFFECTS).optional()
87
+ .describe('Only endpoints with this side effect, e.g. `read` for safe lookups.'),
88
+ page: z.number().int().min(1).max(1_000).default(1).describe('Page number, starting at 1.'),
89
+ size: z.number().int().min(1).max(50).default(20).describe('Results per page (1-50, default 20).'),
90
+ lang: langSchema,
91
+ },
92
+ outputSchema: apiEndpointListOutputSchema,
93
+ annotations: READ_ONLY,
94
+ }, async ({ query, module, method, side_effect, page, size, lang }, extra) => {
95
+ try {
96
+ const filter = {
97
+ q: query || undefined,
98
+ module: module?.toUpperCase(),
99
+ method,
100
+ lang: lang,
101
+ };
102
+ let endpoints;
103
+ let total;
104
+ let totalPages;
105
+ if (side_effect) {
106
+ // The catalog has no side-effect filter: scan it and page locally.
107
+ const matches = [];
108
+ let complete = false;
109
+ for (let serverPage = 1; serverPage <= SIDE_EFFECT_SCAN_MAX_PAGES; serverPage++) {
110
+ const result = await client.listApiEndpoints({ ...filter, page: serverPage, size: SIDE_EFFECT_SCAN_PAGE_SIZE }, extra.signal);
111
+ const items = summariesOf(result.items);
112
+ matches.push(...items.filter(item => item.sideEffect === side_effect));
113
+ const pages = typeof result.totalPages === 'number' ? result.totalPages : undefined;
114
+ if ((result.items?.length ?? 0) < SIDE_EFFECT_SCAN_PAGE_SIZE || (pages !== undefined && serverPage >= pages)) {
115
+ complete = true;
116
+ break;
117
+ }
118
+ }
119
+ total = matches.length;
120
+ totalPages = Math.max(1, Math.ceil(total / size));
121
+ endpoints = matches.slice((page - 1) * size, page * size);
122
+ if (!complete)
123
+ totalPages = Math.max(totalPages, page + 1);
124
+ }
125
+ else {
126
+ const result = await client.listApiEndpoints({ ...filter, page, size }, extra.signal);
127
+ endpoints = summariesOf(result.items);
128
+ total = typeof result.total === 'number' ? result.total : endpoints.length;
129
+ totalPages = typeof result.totalPages === 'number' ? result.totalPages : Math.max(1, Math.ceil(total / size));
130
+ }
131
+ const structured = {
132
+ ...(query ? { query } : {}),
133
+ ...(filter.module ? { module: filter.module } : {}),
134
+ ...(method ? { method } : {}),
135
+ ...(side_effect ? { sideEffect: side_effect } : {}),
136
+ page,
137
+ size,
138
+ total,
139
+ totalPages,
140
+ endpoints,
141
+ };
142
+ if (endpoints.length === 0) {
143
+ const text = total > 0
144
+ ? `No results on page ${page}; there are ${totalPages} page(s).`
145
+ : 'No endpoint you can call matches this search. Use fewer or different keywords (every word must '
146
+ + 'match), try English or Turkish terms, or browse with `list_api_modules`. If the endpoint exists but '
147
+ + 'is not listed, your Teamso account lacks the permission for it.';
148
+ return toolResult(text, structured);
149
+ }
150
+ const table = markdownTable(['Endpoint', 'Summary', 'Effect', 'SMS code', 'File'], endpoints.map(endpoint => [
151
+ `\`${endpoint.id}\``,
152
+ truncateText(endpoint.summary ?? '', 140),
153
+ endpoint.sideEffect,
154
+ endpoint.smsCodeRequired === 'never' ? '' : endpoint.smsCodeRequired,
155
+ endpoint.fileResponse ? 'yes' : '',
156
+ ]));
157
+ const more = page < totalPages ? `\n\nMore results: call again with \`page: ${page + 1}\`.` : '';
158
+ return toolResult(`## API endpoints you can call (${total} found, page ${page}/${totalPages})\n\n${table}${more}\n\n`
159
+ + 'Next: `describe_api_endpoint` with the `id` to see its parameters and body.', {
160
+ ...structured,
161
+ nextAction: {
162
+ tool: 'describe_api_endpoint',
163
+ arguments: { id: endpoints[0].id },
164
+ reason: 'See the parameters, body schema and SMS/file notes of the endpoint before calling it.',
165
+ },
166
+ });
167
+ }
168
+ catch (error) {
169
+ return apiErrorResult(error, {
170
+ notFound: 'This Teamso server does not offer the API catalog yet. Use the specific Teamso tools instead.',
171
+ });
172
+ }
173
+ });
174
+ }
175
+ export function registerListApiModules(server, client) {
176
+ server.registerTool('list_api_modules', {
177
+ title: 'List Teamso API modules',
178
+ description: 'List the Teamso product modules (leave, time tracking, payroll, expenses, recruiting...) with how '
179
+ + 'many API endpoints the caller can use in each. Use a module `code` to narrow `find_api_endpoints`.',
180
+ inputSchema: { lang: langSchema },
181
+ outputSchema: apiModulesOutputSchema,
182
+ annotations: READ_ONLY,
183
+ }, async ({ lang }, extra) => {
184
+ try {
185
+ const modules = (await client.listApiModules(lang, extra.signal))
186
+ .map(toApiModule)
187
+ .filter((module) => module !== null);
188
+ const usable = modules.filter(module => module.allowedCount > 0);
189
+ if (usable.length === 0) {
190
+ return toolResult('Your Teamso account cannot call any catalogued API endpoint.', { modules });
191
+ }
192
+ const table = markdownTable(['Code', 'Module', 'You can call', 'Total'], usable.map(module => [`\`${module.code}\``, module.title, module.allowedCount, module.endpointCount]));
193
+ const hidden = modules.length - usable.length;
194
+ return toolResult(`## Teamso API modules\n\n${table}`
195
+ + (hidden ? `\n\n${hidden} more module(s) have no endpoint you can call.` : ''), {
196
+ modules,
197
+ nextAction: {
198
+ tool: 'find_api_endpoints',
199
+ arguments: { module: usable[0].code },
200
+ reason: 'List the endpoints of the module that fits the user\'s task (add `query` keywords to narrow it).',
201
+ },
202
+ });
203
+ }
204
+ catch (error) {
205
+ return apiErrorResult(error, {
206
+ notFound: 'This Teamso server does not offer the API catalog yet. Use the specific Teamso tools instead.',
207
+ });
208
+ }
209
+ });
210
+ }
211
+ export function registerDescribeApiEndpoint(server, client) {
212
+ server.registerTool('describe_api_endpoint', {
213
+ title: 'Describe a Teamso API endpoint',
214
+ description: 'Show how to call one Teamso API endpoint the caller may use: path and query parameters, the '
215
+ + 'JSON Schema of the request body, response kind (JSON or file), side effect, SMS-code requirement and '
216
+ + 'notes, plus ready-made example arguments for `call_api_read` (reads) or `call_api_write` (changes).',
217
+ inputSchema: {
218
+ id: endpointIdSchema,
219
+ lang: langSchema,
220
+ },
221
+ outputSchema: apiEndpointDescriptionOutputSchema,
222
+ annotations: READ_ONLY,
223
+ }, async ({ id, lang }, extra) => {
224
+ try {
225
+ const details = await loadEndpoint(client, id, lang, extra.signal);
226
+ const example = exampleCall(details);
227
+ const isRead = details.sideEffect === 'read';
228
+ const lines = [
229
+ `## \`${details.id}\``,
230
+ '',
231
+ details.summary ?? '',
232
+ '',
233
+ `- **Effect:** ${effectLabel(details)}`,
234
+ `- **Module:** ${details.moduleTitle ?? details.module ?? '—'}`,
235
+ `- **Access:** ${details.access ?? 'allowed'}${details.accessReason ? ` (${details.accessReason})` : ''}`,
236
+ `- **SMS code:** ${smsLabel(details)}`,
237
+ `- **Response:** ${details.fileResponse ? 'a file (returned as an embedded resource)' : 'JSON'}`
238
+ + `${details.multipart ? '; the request is multipart (use `files`)' : ''}`,
239
+ ];
240
+ const parameters = details.parameters.filter(parameter => parameter.in !== 'header' || parameter.required);
241
+ if (parameters.length) {
242
+ lines.push('', '### Parameters', '', markdownTable(['Name', 'In', 'Required', 'Type'], parameters.map(parameter => [
243
+ `\`${parameter.name}\``,
244
+ parameter.in,
245
+ parameter.required ? 'yes' : 'no',
246
+ typeLabel(parameter.schema),
247
+ ])));
248
+ }
249
+ if (details.requestBody) {
250
+ const schemaText = safeStringify(details.requestBody.schema, 2);
251
+ lines.push('', `### Body (${details.requestBody.contentType}${details.requestBody.required ? ', required' : ''})`, '', '```json', truncateText(schemaText, MAX_TEXT_SCHEMA_CHARS), '```');
252
+ if (schemaText.length > MAX_TEXT_SCHEMA_CHARS) {
253
+ lines.push('', '_Schema shortened here; the full schema is in `structuredContent.requestBody.schema`._');
254
+ }
255
+ }
256
+ if (details.notes.length) {
257
+ lines.push('', '### Notes', '', ...details.notes.map(note => `- ${note}`));
258
+ }
259
+ lines.push('', `### Example \`${example.tool}\` arguments`, '', '```json', safeStringify(example.arguments, 2), '```');
260
+ lines.push('', isRead
261
+ ? 'Replace the `<placeholders>` and call `call_api_read`.'
262
+ : 'Replace the `<placeholders>` and call `call_api_write` without `confirm` to get a preview. Show the '
263
+ + 'preview to the user and repeat the call with `confirm: true` only after they explicitly agree.');
264
+ return toolResult(lines.join('\n'), {
265
+ endpoint: {
266
+ ...summaryOf(details),
267
+ ...(details.accessReason ? { accessReason: details.accessReason } : {}),
268
+ ...(details.permission ? { permission: details.permission } : {}),
269
+ ...(details.moduleDescription ? { moduleDescription: details.moduleDescription } : {}),
270
+ ...(details.responseKind ? { responseKind: details.responseKind } : {}),
271
+ ...(details.doc ? { doc: details.doc } : {}),
272
+ webScreens: details.webScreens,
273
+ },
274
+ sms: details.sms,
275
+ parameters: details.parameters,
276
+ requestBody: details.requestBody,
277
+ notes: details.notes,
278
+ related: details.related,
279
+ example,
280
+ nextAction: {
281
+ tool: example.tool,
282
+ arguments: example.arguments,
283
+ reason: isRead
284
+ ? 'Fill in the placeholders with real values and call it.'
285
+ : 'Fill in the placeholders and call without confirm for a preview; run it with confirm=true only after '
286
+ + 'the user explicitly confirms the preview.'
287
+ + (details.sms && (details.sms.mode === 'always' || details.sms.preVerify)
288
+ ? ' It needs an SMS code: call request_sms_code after the confirmation and pass otp_code.'
289
+ : ''),
290
+ },
291
+ });
292
+ }
293
+ catch (error) {
294
+ return apiErrorResult(error, CATALOG_HINTS);
295
+ }
296
+ });
297
+ }
298
+ export function registerCallApiRead(server, client) {
299
+ server.registerTool('call_api_read', {
300
+ title: 'Read data through the Teamso API',
301
+ description: 'Call a Teamso API endpoint whose side effect is `read` (from `find_api_endpoints` / '
302
+ + '`describe_api_endpoint`) and return its data. The request is marked read-only, so Teamso refuses '
303
+ + 'anything that would change data; endpoints that write are refused here (use `call_api_write`). Large '
304
+ + 'lists are shortened with `truncated: true`; files (PDF, Excel, CSV, images) come back as an embedded '
305
+ + 'resource (up to 8 MB). Runs with the caller\'s own Teamso permissions.',
306
+ inputSchema: {
307
+ id: endpointIdSchema.optional(),
308
+ method: z.enum(HTTP_METHODS).optional().describe('With `path`, instead of `id`.'),
309
+ path: z.string().trim().min(1).max(300).optional()
310
+ .describe('Catalog path pattern with placeholders (e.g. `/leave/{id}`), with `method`, instead of `id`.'),
311
+ path_params: pathParamsSchema,
312
+ query: querySchema,
313
+ body: z.unknown().optional()
314
+ .describe('Only for reads sent as POST (the filter goes in the body); see `describe_api_endpoint`.'),
315
+ lang: langSchema,
316
+ },
317
+ outputSchema: apiReadOutputSchema,
318
+ annotations: READ_ONLY,
319
+ }, async ({ id, method, path, path_params, query, body, lang }, extra) => {
320
+ try {
321
+ const endpointId = endpointIdOf(id, method, path);
322
+ const details = await loadEndpoint(client, endpointId, lang, extra.signal);
323
+ if (details.sideEffect !== 'read') {
324
+ return errorResult(`\`${details.id}\` ${effectLabel(details).toLowerCase()}, so \`call_api_read\` does not run `
325
+ + 'it. Use `call_api_write` with the same arguments: it shows a preview first, and runs only after the '
326
+ + 'user confirms.');
327
+ }
328
+ const prepared = prepareRequest(details, { path_params, query, body });
329
+ if (!prepared.validation.valid) {
330
+ return errorResult(`The request does not match \`${details.id}\`:\n\n`
331
+ + prepared.validation.errors.map(problem => `- ${problem}`).join('\n')
332
+ + '\n\nCheck the parameters with `describe_api_endpoint`.');
333
+ }
334
+ const response = await client.callApi({
335
+ method: details.method,
336
+ path: prepared.path,
337
+ query: prepared.query,
338
+ body: prepared.body,
339
+ readIntent: true,
340
+ maxResponseBytes: details.fileResponse ? MAX_FILE_BYTES : MAX_JSON_RESPONSE_BYTES,
341
+ }, extra.signal);
342
+ const request = { method: details.method, path: displayPath(prepared.path, prepared.query) };
343
+ const rendered = renderResponse(details, request.path, response);
344
+ return {
345
+ content: rendered.content,
346
+ structuredContent: {
347
+ endpoint: summaryOf(details),
348
+ request,
349
+ ...rendered.structured,
350
+ warnings: prepared.warnings,
351
+ },
352
+ };
353
+ }
354
+ catch (error) {
355
+ return callErrorResult(error, true);
356
+ }
357
+ });
358
+ }
359
+ export function registerCallApiWrite(server, client) {
360
+ server.registerTool('call_api_write', {
361
+ title: 'Change data through the Teamso API',
362
+ description: 'Create, update, approve or delete something through a Teamso API endpoint from '
363
+ + '`find_api_endpoints` (side effect `write`, `delete` or `read_with_writes`). Without `confirm` it only '
364
+ + 'returns a PREVIEW (resolved request, body checked against the endpoint\'s JSON Schema, warnings, SMS-code '
365
+ + 'need) and sends nothing. Show the preview to the user; only after they explicitly confirm, call again '
366
+ + 'with the same arguments and `confirm: true`. Endpoints that need an SMS code also need `otp_code` '
367
+ + '(see `request_sms_code`). Runs with the caller\'s own Teamso permissions.',
368
+ inputSchema: {
369
+ id: endpointIdSchema,
370
+ path_params: pathParamsSchema,
371
+ query: querySchema,
372
+ body: z.unknown().optional()
373
+ .describe('Request body: JSON matching the endpoint\'s body schema. For multipart endpoints, an object of '
374
+ + 'the non-file form fields.'),
375
+ files: filesSchema,
376
+ confirm: z.boolean().default(false)
377
+ .describe('false (default): preview only, nothing is sent. true: run it — only after the user explicitly '
378
+ + 'confirmed the preview.'),
379
+ otp_code: otpCodeSchema.optional()
380
+ .describe('SMS verification code from `request_sms_code`, for endpoints that need one (valid for 3 minutes).'),
381
+ lang: langSchema,
382
+ },
383
+ outputSchema: apiWriteOutputSchema,
384
+ annotations: DESTRUCTIVE_WRITE,
385
+ }, async ({ id, path_params, query, body, files, confirm, otp_code, lang }, extra) => {
386
+ try {
387
+ const details = await loadEndpoint(client, id, lang, extra.signal);
388
+ if (details.sideEffect === 'read') {
389
+ return errorResult(`\`${details.id}\` only reads data. Use \`call_api_read\` with the same arguments; `
390
+ + 'it needs no confirmation.');
391
+ }
392
+ const prepared = prepareRequest(details, { path_params, query, body, files });
393
+ const warnings = [...prepared.warnings];
394
+ const sms = details.sms;
395
+ const codeFirst = Boolean(sms && (sms.mode === 'always' || sms.preVerify));
396
+ if (otp_code && !sms) {
397
+ warnings.push('`otp_code` is not needed by this endpoint and is not used.');
398
+ }
399
+ const request = { method: details.method, path: displayPath(prepared.path, prepared.query) };
400
+ const summary = humanSummary(details, request.path);
401
+ const common = {
402
+ endpoint: summaryOf(details),
403
+ request,
404
+ summary,
405
+ validation: prepared.validation,
406
+ sms,
407
+ files: prepared.files,
408
+ };
409
+ if (!confirm) {
410
+ const previewBody = previewBodyOf(body, prepared.body, warnings);
411
+ const nextAction = previewNextAction(details, { id, path_params, query, body, files, otp_code, lang }, prepared.validation, codeFirst && !otp_code);
412
+ return toolResult(previewText(details, request.path, summary, prepared, previewBody, warnings), {
413
+ mode: 'preview',
414
+ ...common,
415
+ ...(previewBody !== undefined ? { body: previewBody } : {}),
416
+ warnings,
417
+ nextAction,
418
+ });
419
+ }
420
+ if (!prepared.validation.valid) {
421
+ return errorResult(`Nothing was sent: the request does not match \`${details.id}\`.\n\n`
422
+ + prepared.validation.errors.map(problem => `- ${problem}`).join('\n')
423
+ + '\n\nFix it (see `describe_api_endpoint`), preview again and get the user\'s confirmation.');
424
+ }
425
+ if (codeFirst && !otp_code) {
426
+ return errorResult(`Nothing was sent: \`${details.id}\` needs an SMS verification code`
427
+ + `${sms?.preVerify ? ' before the first attempt' : ''}. ${OTP_FLOW}`);
428
+ }
429
+ const call = () => client.callApi({
430
+ method: details.method,
431
+ path: prepared.path,
432
+ query: prepared.query,
433
+ body: prepared.body,
434
+ maxResponseBytes: details.fileResponse ? MAX_FILE_BYTES : MAX_JSON_RESPONSE_BYTES,
435
+ }, extra.signal);
436
+ let smsVerified = false;
437
+ // Verify only right before the call: a verified code unlocks the next SMS-protected action.
438
+ if (codeFirst && otp_code) {
439
+ await client.verifyOtp(otp_code, extra.signal);
440
+ smsVerified = true;
441
+ }
442
+ let response;
443
+ try {
444
+ response = await call();
445
+ }
446
+ catch (error) {
447
+ // Conditional SMS: the API asks for the code only in some cases. Verify and retry once.
448
+ if (!smsVerified && otp_code && error instanceof TeamsoApiError && error.messageCode === 'OTP_NOT_DONE') {
449
+ await client.verifyOtp(otp_code, extra.signal);
450
+ smsVerified = true;
451
+ response = await call();
452
+ }
453
+ else {
454
+ throw error;
455
+ }
456
+ }
457
+ const rendered = renderResponse(details, request.path, response, 'Done');
458
+ return {
459
+ content: rendered.content,
460
+ structuredContent: {
461
+ mode: 'executed',
462
+ ...common,
463
+ smsVerified,
464
+ warnings,
465
+ ...rendered.structured,
466
+ },
467
+ };
468
+ }
469
+ catch (error) {
470
+ return callErrorResult(error, false);
471
+ }
472
+ });
473
+ }
474
+ export function registerRequestSmsCode(server, client) {
475
+ server.registerTool('request_sms_code', {
476
+ title: 'Send an SMS verification code',
477
+ description: 'Send a 6-digit SMS verification code to the caller\'s phone, for API endpoints that need one '
478
+ + '(`smsCodeRequired` in `find_api_endpoints`, e.g. expense finalisation, payslip approval, overtime '
479
+ + 'conversion). The code is valid for 3 minutes, a new code invalidates the previous one, and each SMS uses '
480
+ + 'company digital credit, so call it only after the user confirmed the action. Then ask the user for the '
481
+ + 'code and pass it as `otp_code` to `call_api_write`. (`request_leave_otp` does the same for leave tools.)',
482
+ inputSchema: {},
483
+ outputSchema: otpOutputSchema,
484
+ annotations: ADDITIVE_WRITE,
485
+ }, async (_args, extra) => {
486
+ try {
487
+ await client.requestOtp(extra.signal);
488
+ const message = 'An SMS code was sent to your phone number registered in Teamso. It is valid for 3 minutes.';
489
+ return toolResult(`${message}\n\nAsk the user for the code, then call \`call_api_write\` again with the same `
490
+ + 'arguments, `confirm: true` and `otp_code`.', {
491
+ sent: true,
492
+ validForSeconds: OTP_VALIDITY_SECONDS,
493
+ message,
494
+ nextAction: {
495
+ tool: 'call_api_write',
496
+ arguments: { confirm: true, otp_code: '<code from the SMS>' },
497
+ reason: 'Repeat the confirmed call_api_write call (same id, path_params, query, body and files) with '
498
+ + 'the code within 3 minutes.',
499
+ },
500
+ });
501
+ }
502
+ catch (error) {
503
+ return apiErrorResult(error, {
504
+ byCode: {
505
+ USER_NOT_FOUND: 'Teamso could not find a phone number for your account to send the SMS code to.',
506
+ },
507
+ });
508
+ }
509
+ });
510
+ }
511
+ // ----------------------------------------------------------------- helpers
512
+ async function loadEndpoint(client, id, lang, signal) {
513
+ const parsed = splitEndpointId(id);
514
+ if (!parsed)
515
+ throw new ToolInputError('`id` must look like "METHOD /path", e.g. "GET /leave/{id}".');
516
+ let description;
517
+ try {
518
+ description = await client.getApiEndpoint(`${parsed.method} ${parsed.path}`, lang, signal);
519
+ }
520
+ catch (error) {
521
+ if (error instanceof TeamsoApiError && (error.status === 404 || error.messageCode === 'ITEM_NOT_FOUND')) {
522
+ throw new ToolInputError(`${CATALOG_HINTS.notFound}\n\n_Teamso API: HTTP ${error.status} · \`ITEM_NOT_FOUND\`_`);
523
+ }
524
+ throw error;
525
+ }
526
+ const details = toApiEndpointDetails(description);
527
+ if (!details) {
528
+ throw new ToolInputError('The Teamso API catalog returned an incomplete description for this endpoint. '
529
+ + 'Try again later or use the Teamso app.');
530
+ }
531
+ // Never call anything other than what the catalog described.
532
+ if (details.id !== `${parsed.method} ${parsed.path}`) {
533
+ throw new ToolInputError(`The catalog described \`${details.id}\` instead of \`${parsed.method} ${parsed.path}\`; `
534
+ + 'nothing was called. Use the exact id from `find_api_endpoints`.');
535
+ }
536
+ return details;
537
+ }
538
+ function endpointIdOf(id, method, path) {
539
+ if (id)
540
+ return id;
541
+ if (method && path) {
542
+ if (!path.startsWith('/'))
543
+ throw new ToolInputError('`path` must start with "/" (a catalog path such as `/leave/{id}`).');
544
+ return `${method} ${path}`;
545
+ }
546
+ throw new ToolInputError('Pass `id` (e.g. "GET /leave/{id}") or both `method` and `path`. '
547
+ + 'Find ids with `find_api_endpoints`.');
548
+ }
549
+ function summariesOf(items) {
550
+ return (Array.isArray(items) ? items : [])
551
+ .filter(isRecord)
552
+ .map(item => toApiEndpointSummary(item))
553
+ .filter((item) => item !== null);
554
+ }
555
+ function summaryOf(details) {
556
+ return {
557
+ id: details.id,
558
+ method: details.method,
559
+ path: details.path,
560
+ ...(details.module ? { module: details.module } : {}),
561
+ ...(details.moduleTitle ? { moduleTitle: details.moduleTitle } : {}),
562
+ ...(details.summary ? { summary: details.summary } : {}),
563
+ sideEffect: details.sideEffect,
564
+ smsCodeRequired: details.smsCodeRequired,
565
+ fileResponse: details.fileResponse,
566
+ multipart: details.multipart,
567
+ ...(details.access ? { access: details.access } : {}),
568
+ ...(details.conditional !== undefined ? { conditional: details.conditional } : {}),
569
+ };
570
+ }
571
+ /** Resolves the path, checks the query and builds and validates the body. Problems go into `validation`. */
572
+ function prepareRequest(details, input) {
573
+ const problems = [];
574
+ const pathValues = input.path_params ?? {};
575
+ const resolved = resolvePath(details.path, pathValues);
576
+ problems.push(...resolved.problems);
577
+ const query = input.query ?? {};
578
+ const queryCheck = checkQuery(details.parameters, query);
579
+ problems.push(...queryCheck.problems);
580
+ const warnings = [...queryCheck.warnings];
581
+ let body;
582
+ let checked = true;
583
+ const files = [];
584
+ const requestBody = details.requestBody;
585
+ const hasBody = input.body !== undefined && input.body !== null;
586
+ const hasFiles = (input.files?.length ?? 0) > 0;
587
+ if (details.method === 'GET' && (hasBody || hasFiles)) {
588
+ problems.push('GET requests carry no body or files; pass inputs in `query` or `path_params`.');
589
+ }
590
+ else if (!requestBody) {
591
+ if (hasBody)
592
+ problems.push('This endpoint takes no request body; pass inputs in `query` or `path_params`.');
593
+ if (hasFiles)
594
+ problems.push('This endpoint does not accept file uploads.');
595
+ }
596
+ else if (details.multipart || requestBody.contentType.startsWith('multipart/')) {
597
+ const schema = requestBody.schema;
598
+ const properties = isRecord(schema.properties) ? schema.properties : {};
599
+ const fileFields = Object.keys(properties).filter(name => isBinarySchema(properties[name]));
600
+ const fields = {};
601
+ const shape = {};
602
+ if (hasBody) {
603
+ if (!isRecord(input.body)) {
604
+ problems.push('For multipart endpoints `body` must be an object of form fields (files go in `files`).');
605
+ }
606
+ else {
607
+ for (const [name, value] of Object.entries(input.body)) {
608
+ const values = Array.isArray(value) ? value : [value];
609
+ if (values.some(item => item !== null && typeof item === 'object')) {
610
+ problems.push(`body.${name}: multipart form fields must be text, numbers or booleans.`);
611
+ continue;
612
+ }
613
+ fields[name] = values.filter(item => item !== null && item !== undefined).map(String);
614
+ shape[name] = value;
615
+ }
616
+ }
617
+ }
618
+ const uploads = [];
619
+ let totalBytes = 0;
620
+ for (const file of input.files ?? []) {
621
+ if (fileFields.length && !fileFields.includes(file.field)) {
622
+ problems.push(`files: "${file.field}" is not a file field of this endpoint (file fields: ${fileFields.join(', ')}).`);
623
+ continue;
624
+ }
625
+ const data = decodeBase64(file.base64);
626
+ if (!data) {
627
+ problems.push(`files: "${file.filename}" is not valid base64.`);
628
+ continue;
629
+ }
630
+ totalBytes += data.byteLength;
631
+ const filename = file.filename.replace(/[\\/]/g, '_').replace(/[\u0000-\u001f\u007f"]/g, '');
632
+ uploads.push({ field: file.field, filename, contentType: file.content_type, data });
633
+ files.push({ field: file.field, filename, contentType: file.content_type, sizeBytes: data.byteLength });
634
+ const existing = shape[file.field];
635
+ const isArrayField = isRecord(properties[file.field]) && properties[file.field].type === 'array';
636
+ shape[file.field] = isArrayField
637
+ ? [...(Array.isArray(existing) ? existing : []), '<file>']
638
+ : '<file>';
639
+ }
640
+ if (totalBytes > MAX_UPLOAD_BYTES) {
641
+ problems.push(`files: ${formatBytes(totalBytes)} in total; at most ${formatBytes(MAX_UPLOAD_BYTES)} can be uploaded per call.`);
642
+ }
643
+ const validation = validateBody(schema, shape);
644
+ checked = validation.checked;
645
+ problems.push(...validation.errors);
646
+ body = { kind: 'multipart', fields, files: uploads };
647
+ }
648
+ else {
649
+ if (hasFiles)
650
+ problems.push('This endpoint does not accept file uploads; send the data in `body`.');
651
+ if (!hasBody) {
652
+ if (requestBody.required)
653
+ problems.push('body is required (see the body schema in `describe_api_endpoint`).');
654
+ }
655
+ else {
656
+ const text = safeStringify(input.body);
657
+ if (text.length > MAX_JSON_BODY_CHARS) {
658
+ problems.push(`body is too large (${formatBytes(text.length)}; at most ${formatBytes(MAX_JSON_BODY_CHARS)}).`);
659
+ }
660
+ const validation = validateBody(requestBody.schema, input.body);
661
+ checked = validation.checked;
662
+ problems.push(...validation.errors);
663
+ if (requestBody.contentType.startsWith('text/')) {
664
+ if (typeof input.body !== 'string')
665
+ problems.push('body must be a string (this endpoint takes plain text).');
666
+ else
667
+ body = { kind: 'text', value: input.body, contentType: requestBody.contentType };
668
+ }
669
+ else {
670
+ body = { kind: 'json', value: input.body };
671
+ }
672
+ }
673
+ }
674
+ if (!checked)
675
+ warnings.push('The body schema could not be checked; Teamso validates the body itself.');
676
+ return {
677
+ path: resolved.path,
678
+ query,
679
+ body,
680
+ validation: { checked, valid: problems.length === 0, errors: problems },
681
+ warnings,
682
+ files,
683
+ };
684
+ }
685
+ function decodeBase64(value) {
686
+ const cleaned = value.replace(/^data:[^,]*;base64,/i, '').replace(/\s+/g, '').replace(/-/g, '+').replace(/_/g, '/');
687
+ if (!/^[A-Za-z0-9+/]*={0,2}$/.test(cleaned) || cleaned.length % 4 === 1)
688
+ return null;
689
+ return new Uint8Array(Buffer.from(cleaned, 'base64'));
690
+ }
691
+ function renderResponse(details, requestPath, response, verb = 'Result') {
692
+ if (response.kind === 'file') {
693
+ if (response.data.byteLength > MAX_FILE_BYTES) {
694
+ throw new TeamsoResponseTooLargeError(MAX_FILE_BYTES, response.data.byteLength);
695
+ }
696
+ if (response.data.byteLength === 0) {
697
+ throw new ToolInputError('Teamso returned an empty file. Check the parameters, or open it in the Teamso app.');
698
+ }
699
+ const fileName = response.fileName ?? fallbackFileName(details.path, response.contentType);
700
+ const resourceUri = `teamso://api/files/${encodeURIComponent(fileName)}`;
701
+ const file = {
702
+ fileName,
703
+ mimeType: response.contentType,
704
+ sizeBytes: response.data.byteLength,
705
+ resourceUri,
706
+ };
707
+ return {
708
+ content: [
709
+ {
710
+ type: 'text',
711
+ text: `## ${verb}: \`${details.method} ${requestPath}\`\n\nFile \`${fileName}\` (${response.contentType}, `
712
+ + `${formatBytes(response.data.byteLength)}) is attached.`,
713
+ },
714
+ {
715
+ type: 'resource',
716
+ resource: {
717
+ uri: resourceUri,
718
+ mimeType: response.contentType,
719
+ blob: Buffer.from(response.data).toString('base64'),
720
+ },
721
+ },
722
+ ],
723
+ structured: { responseType: 'file', status: response.status, truncated: false, file },
724
+ };
725
+ }
726
+ const shaped = shapeResult(response.result);
727
+ const lines = [
728
+ `## ${verb}: \`${details.method} ${requestPath}\``,
729
+ '',
730
+ `Teamso answered${response.messageCode ? ` \`${response.messageCode}\`` : ` HTTP ${response.status}`}`
731
+ + `${response.message && response.message !== response.messageCode ? ` (${response.message})` : ''}.`,
732
+ ];
733
+ if (shaped.resultPreview !== undefined) {
734
+ lines.push('', '```json', shaped.resultPreview, '```');
735
+ }
736
+ else if (shaped.result !== undefined && shaped.result !== null) {
737
+ const compact = safeStringify(shaped.result);
738
+ lines.push('', '```json', compact.length <= 8_000 ? safeStringify(shaped.result, 2) : compact, '```');
739
+ }
740
+ else {
741
+ lines.push('', 'No data in the response.');
742
+ }
743
+ if (shaped.truncated && shaped.truncation)
744
+ lines.push('', `**Truncated:** ${shaped.truncation.hint}`);
745
+ return {
746
+ content: [{ type: 'text', text: lines.join('\n') }],
747
+ structured: {
748
+ responseType: 'json',
749
+ status: response.status,
750
+ ...(response.messageCode ? { messageCode: response.messageCode } : {}),
751
+ ...(response.message ? { message: response.message } : {}),
752
+ ...(shaped.result !== undefined ? { result: shaped.result } : {}),
753
+ truncated: shaped.truncated,
754
+ ...(shaped.truncation ? { truncation: shaped.truncation } : {}),
755
+ ...(shaped.resultPreview !== undefined ? { resultPreview: shaped.resultPreview } : {}),
756
+ },
757
+ };
758
+ }
759
+ function isEmptyReadAnswer(error) {
760
+ return error instanceof TeamsoApiError
761
+ && error.status === 200
762
+ && (error.messageCode === 'GET_ERROR' || error.messageCode === 'ITEM_NOT_FOUND')
763
+ && (error.result === undefined || error.result === null);
764
+ }
765
+ function callErrorResult(error, readMode) {
766
+ if (error instanceof TeamsoResponseTooLargeError) {
767
+ return errorResult(`${error.message} MCP returns files up to ${formatBytes(MAX_FILE_BYTES)} and shortens long `
768
+ + 'lists; this response is too large. Narrow it (filters, a shorter date range, `page`/`size`) or download '
769
+ + 'it in the Teamso app.');
770
+ }
771
+ if (readMode && error instanceof TeamsoApiError && error.status === 403
772
+ && isRecord(error.body) && error.body.error === 'MCP_READ_INTENT') {
773
+ return errorResult('Teamso refused this as a read because the endpoint changes data. Use `call_api_write` '
774
+ + '(preview, then confirm with the user).');
775
+ }
776
+ if (readMode && isEmptyReadAnswer(error)) {
777
+ // Many Teamso lists answer `GET_ERROR` / `ITEM_NOT_FOUND` with HTTP 200 and no data when nothing matches.
778
+ return errorResult(`Teamso returned no data (\`${error.messageCode}\`${error.apiMessage && error.apiMessage !== error.messageCode
779
+ ? `: "${error.apiMessage}"` : ''}). Teamso list and search endpoints answer this way when there is nothing `
780
+ + 'to show, so for a list this most likely means there are no matching records: tell the user that '
781
+ + 'instead of reporting a failure (check date ranges and filters first). For a single record fetched by '
782
+ + 'id, it was not found or is not visible to you.\n\n'
783
+ + `_Teamso API: HTTP ${error.status} · \`${error.messageCode}\`_`);
784
+ }
785
+ return apiErrorResult(error, CALL_HINTS);
786
+ }
787
+ function effectLabel(details) {
788
+ switch (details.sideEffect) {
789
+ case 'read': return 'Reads data';
790
+ case 'delete': return 'Deletes data';
791
+ case 'read_with_writes': return 'Changes data (although it is a GET)';
792
+ default: return 'Changes data';
793
+ }
794
+ }
795
+ function smsLabel(details) {
796
+ const sms = details.sms;
797
+ if (!sms)
798
+ return 'not needed';
799
+ const parts = [sms.mode === 'always' ? 'required' : `only when ${sms.when ?? 'Teamso asks for it (OTP_NOT_DONE)'}`];
800
+ if (sms.preVerify)
801
+ parts.push('verify before the first attempt');
802
+ if (sms.credits && sms.credits !== 'none')
803
+ parts.push(`uses digital credits (${sms.credits})`);
804
+ return parts.join('; ');
805
+ }
806
+ function typeLabel(schema) {
807
+ const type = typeof schema.type === 'string' ? schema.type : schema.$ref ? 'object' : 'any';
808
+ const detail = schema['x-pattern'] ?? schema.format ?? (Array.isArray(schema.enum) ? schema.enum.join(', ') : undefined);
809
+ const fallback = schema.default !== undefined ? `, default ${String(schema.default)}` : '';
810
+ return `${type}${detail ? ` (${String(detail)})` : ''}${fallback}`;
811
+ }
812
+ function humanSummary(details, requestPath) {
813
+ const parts = [`${effectLabel(details)}: ${details.method} ${requestPath}`];
814
+ if (details.summary)
815
+ parts.push(`— ${truncateText(details.summary, 160)}`);
816
+ if (details.sms) {
817
+ parts.push(details.sms.mode === 'always' || details.sms.preVerify
818
+ ? '(needs an SMS code)'
819
+ : '(may need an SMS code)');
820
+ }
821
+ return parts.join(' ');
822
+ }
823
+ function previewBodyOf(input, body, warnings) {
824
+ if (body?.kind === 'multipart')
825
+ return Object.keys(body.fields).length ? body.fields : undefined;
826
+ if (input === undefined || input === null)
827
+ return undefined;
828
+ if (safeStringify(input).length > MAX_PREVIEW_BODY_CHARS) {
829
+ warnings.push('The body is too long to repeat in the preview; it is sent as given.');
830
+ return undefined;
831
+ }
832
+ return input;
833
+ }
834
+ function previewText(details, requestPath, summary, prepared, previewBody, warnings) {
835
+ const lines = [
836
+ '## Preview — nothing has been sent',
837
+ '',
838
+ `**${summary}**`,
839
+ '',
840
+ `- **Request:** \`${details.method} ${requestPath}\``,
841
+ ];
842
+ if (details.sideEffect === 'delete')
843
+ lines.push('- **Warning:** this deletes data in Teamso; it may not be undoable.');
844
+ if (details.sideEffect === 'read_with_writes')
845
+ lines.push('- **Warning:** this GET endpoint changes data.');
846
+ lines.push(`- **SMS code:** ${smsLabel(details)}`);
847
+ if (prepared.files.length) {
848
+ lines.push(`- **Files:** ${prepared.files.map(file => `${escapeCell(file.filename)} → \`${file.field}\` (${formatBytes(file.sizeBytes)})`).join(', ')}`);
849
+ }
850
+ if (previewBody !== undefined) {
851
+ lines.push('', '```json', truncateText(safeStringify(previewBody, 2), 4_000), '```');
852
+ }
853
+ if (prepared.validation.valid) {
854
+ lines.push('', prepared.validation.checked ? 'The request matches the endpoint\'s schema.' : 'The body could not be checked.');
855
+ }
856
+ else {
857
+ lines.push('', '**Fix before running:**', ...prepared.validation.errors.map(problem => `- ${problem}`));
858
+ }
859
+ if (warnings.length)
860
+ lines.push('', '**Notes:**', ...warnings.map(warning => `- ${warning}`));
861
+ lines.push('', prepared.validation.valid
862
+ ? 'Show this to the user. Run it only after they explicitly confirm: call `call_api_write` again with the same '
863
+ + 'arguments and `confirm: true`.'
864
+ + (details.sms && (details.sms.mode === 'always' || details.sms.preVerify)
865
+ ? ' It needs an SMS code: after the confirmation call `request_sms_code` and pass the code as `otp_code`.'
866
+ : '')
867
+ : 'Correct the arguments and preview again.');
868
+ return lines.join('\n');
869
+ }
870
+ function previewNextAction(details, input, validation, needsCode) {
871
+ if (!validation.valid) {
872
+ return {
873
+ tool: 'describe_api_endpoint',
874
+ arguments: { id: details.id },
875
+ reason: 'The request does not match the endpoint; check its parameters and body schema, fix the arguments and '
876
+ + 'preview again.',
877
+ };
878
+ }
879
+ if (needsCode) {
880
+ return {
881
+ tool: 'request_sms_code',
882
+ arguments: {},
883
+ reason: 'After the user explicitly confirms this preview, send the SMS code, ask the user for it, then call '
884
+ + 'call_api_write with the same arguments, confirm=true and otp_code.',
885
+ };
886
+ }
887
+ const args = { id: input.id };
888
+ if (input.path_params)
889
+ args.path_params = input.path_params;
890
+ if (input.query)
891
+ args.query = input.query;
892
+ if (input.body !== undefined)
893
+ args.body = input.body;
894
+ if (input.otp_code)
895
+ args.otp_code = input.otp_code;
896
+ args.confirm = true;
897
+ return {
898
+ tool: 'call_api_write',
899
+ arguments: args,
900
+ reason: 'Only after the user explicitly confirms this preview.'
901
+ + (input.files?.length ? ' Pass the same `files` again.' : ''),
902
+ };
903
+ }
904
+ function exampleCall(details) {
905
+ const tool = details.sideEffect === 'read' ? 'call_api_read' : 'call_api_write';
906
+ const args = { id: details.id };
907
+ const pathParams = {};
908
+ for (const name of pathVariables(details.path))
909
+ pathParams[name] = `<${name}>`;
910
+ if (Object.keys(pathParams).length)
911
+ args.path_params = pathParams;
912
+ const query = {};
913
+ for (const parameter of details.parameters) {
914
+ if (parameter.in === 'query' && parameter.required) {
915
+ query[parameter.name] = exampleFromSchema(parameter.schema, parameter.schema) ?? `<${parameter.name}>`;
916
+ }
917
+ }
918
+ if (Object.keys(query).length)
919
+ args.query = query;
920
+ const requestBody = details.requestBody;
921
+ if (requestBody && details.method !== 'GET') {
922
+ const schema = requestBody.schema;
923
+ if (details.multipart || requestBody.contentType.startsWith('multipart/')) {
924
+ const properties = isRecord(schema.properties) ? schema.properties : {};
925
+ const fields = {};
926
+ const files = [];
927
+ for (const [name, property] of Object.entries(properties)) {
928
+ if (isBinarySchema(property)) {
929
+ files.push({ field: name, filename: '<file name>', content_type: '<mime type>', base64: '<base64 content>' });
930
+ }
931
+ else {
932
+ fields[name] = exampleFromSchema(property, schema);
933
+ }
934
+ }
935
+ if (Object.keys(fields).length)
936
+ args.body = fields;
937
+ if (files.length)
938
+ args.files = files;
939
+ }
940
+ else {
941
+ const example = exampleFromSchema(schema, schema);
942
+ if (example !== null || requestBody.required)
943
+ args.body = example;
944
+ }
945
+ }
946
+ return { tool, arguments: args };
947
+ }
948
+ function fallbackFileName(path, contentType) {
949
+ const base = path.split('/').filter(part => part && !part.startsWith('{')).pop() ?? 'download';
950
+ const extension = MIME_EXTENSIONS[contentType] ?? 'bin';
951
+ return `${base}.${extension}`;
952
+ }
953
+ const MIME_EXTENSIONS = {
954
+ 'application/pdf': 'pdf',
955
+ 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': 'xlsx',
956
+ 'application/vnd.ms-excel': 'xls',
957
+ 'text/csv': 'csv',
958
+ 'application/xml': 'xml',
959
+ 'text/xml': 'xml',
960
+ 'application/zip': 'zip',
961
+ 'image/png': 'png',
962
+ 'image/jpeg': 'jpg',
963
+ 'image/gif': 'gif',
964
+ 'image/webp': 'webp',
965
+ 'text/plain': 'txt',
966
+ 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': 'docx',
967
+ };
968
+ function formatBytes(bytes) {
969
+ if (bytes < 1024)
970
+ return `${bytes} B`;
971
+ if (bytes < 1024 * 1024)
972
+ return `${Math.ceil(bytes / 1024)} KB`;
973
+ return `${Math.round((bytes / (1024 * 1024)) * 10) / 10} MB`;
974
+ }
975
+ function truncateText(value, maxLength) {
976
+ if (value.length <= maxLength)
977
+ return value;
978
+ return `${value.slice(0, Math.max(0, maxLength - 1)).trimEnd()}…`;
979
+ }
980
+ //# sourceMappingURL=api.js.map