boondmanager-mcp-server 2.15.2 → 2.17.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 (272) hide show
  1. package/LICENSE +1 -1
  2. package/NOTICE +16 -2
  3. package/README.md +60 -15
  4. package/dist/config/access-policy.d.ts.map +1 -1
  5. package/dist/config/access-policy.js +6 -18
  6. package/dist/config/access-policy.js.map +1 -1
  7. package/dist/config/dictionary-overrides.d.ts.map +1 -1
  8. package/dist/config/dictionary-overrides.js +2 -12
  9. package/dist/config/dictionary-overrides.js.map +1 -1
  10. package/dist/config/env.d.ts +63 -0
  11. package/dist/config/env.d.ts.map +1 -0
  12. package/dist/config/env.js +116 -0
  13. package/dist/config/env.js.map +1 -0
  14. package/dist/config/profiles.d.ts +22 -8
  15. package/dist/config/profiles.d.ts.map +1 -1
  16. package/dist/config/profiles.js +48 -4
  17. package/dist/config/profiles.js.map +1 -1
  18. package/dist/constants.d.ts +13 -2
  19. package/dist/constants.d.ts.map +1 -1
  20. package/dist/constants.js +25 -1
  21. package/dist/constants.js.map +1 -1
  22. package/dist/icons.d.ts.map +1 -1
  23. package/dist/icons.js +6 -1
  24. package/dist/icons.js.map +1 -1
  25. package/dist/index.js +33 -15
  26. package/dist/index.js.map +1 -1
  27. package/dist/instructions.d.ts +1 -1
  28. package/dist/instructions.d.ts.map +1 -1
  29. package/dist/instructions.js +6 -6
  30. package/dist/instructions.js.map +1 -1
  31. package/dist/prompts/index.d.ts +19 -1
  32. package/dist/prompts/index.d.ts.map +1 -1
  33. package/dist/prompts/index.js +495 -25
  34. package/dist/prompts/index.js.map +1 -1
  35. package/dist/prompts/periods.d.ts +45 -0
  36. package/dist/prompts/periods.d.ts.map +1 -0
  37. package/dist/prompts/periods.js +154 -0
  38. package/dist/prompts/periods.js.map +1 -0
  39. package/dist/resources/index.d.ts +14 -0
  40. package/dist/resources/index.d.ts.map +1 -1
  41. package/dist/resources/index.js +285 -0
  42. package/dist/resources/index.js.map +1 -1
  43. package/dist/resources/templates.d.ts +1 -1
  44. package/dist/resources/templates.d.ts.map +1 -1
  45. package/dist/resources/templates.js +2 -0
  46. package/dist/resources/templates.js.map +1 -1
  47. package/dist/schema-dialect.js.map +1 -1
  48. package/dist/schemas/filter-aliases.d.ts +1 -1
  49. package/dist/schemas/filter-aliases.d.ts.map +1 -1
  50. package/dist/schemas/filter-aliases.js +101 -2
  51. package/dist/schemas/filter-aliases.js.map +1 -1
  52. package/dist/schemas/index.d.ts +987 -161
  53. package/dist/schemas/index.d.ts.map +1 -1
  54. package/dist/schemas/index.js +853 -323
  55. package/dist/schemas/index.js.map +1 -1
  56. package/dist/server.d.ts.map +1 -1
  57. package/dist/server.js +10 -3
  58. package/dist/server.js.map +1 -1
  59. package/dist/services/boond-client.d.ts +29 -213
  60. package/dist/services/boond-client.d.ts.map +1 -1
  61. package/dist/services/boond-client.js +29 -1168
  62. package/dist/services/boond-client.js.map +1 -1
  63. package/dist/services/dictionary.d.ts +27 -3
  64. package/dist/services/dictionary.d.ts.map +1 -1
  65. package/dist/services/dictionary.js +60 -23
  66. package/dist/services/dictionary.js.map +1 -1
  67. package/dist/services/document-text.d.ts +32 -0
  68. package/dist/services/document-text.d.ts.map +1 -0
  69. package/dist/services/document-text.js +120 -0
  70. package/dist/services/document-text.js.map +1 -0
  71. package/dist/services/format/detail.d.ts +16 -0
  72. package/dist/services/format/detail.d.ts.map +1 -0
  73. package/dist/services/format/detail.js +36 -0
  74. package/dist/services/format/detail.js.map +1 -0
  75. package/dist/services/format/html.d.ts +19 -0
  76. package/dist/services/format/html.d.ts.map +1 -0
  77. package/dist/services/format/html.js +87 -0
  78. package/dist/services/format/html.js.map +1 -0
  79. package/dist/services/format/list.d.ts +3 -0
  80. package/dist/services/format/list.d.ts.map +1 -0
  81. package/dist/services/format/list.js +44 -0
  82. package/dist/services/format/list.js.map +1 -0
  83. package/dist/services/format/summary.d.ts +16 -0
  84. package/dist/services/format/summary.d.ts.map +1 -0
  85. package/dist/services/format/summary.js +172 -0
  86. package/dist/services/format/summary.js.map +1 -0
  87. package/dist/services/format/tab.d.ts +9 -0
  88. package/dist/services/format/tab.d.ts.map +1 -0
  89. package/dist/services/format/tab.js +32 -0
  90. package/dist/services/format/tab.js.map +1 -0
  91. package/dist/services/http/auth.d.ts +44 -0
  92. package/dist/services/http/auth.d.ts.map +1 -0
  93. package/dist/services/http/auth.js +146 -0
  94. package/dist/services/http/auth.js.map +1 -0
  95. package/dist/services/http/download.d.ts +60 -0
  96. package/dist/services/http/download.d.ts.map +1 -0
  97. package/dist/services/http/download.js +160 -0
  98. package/dist/services/http/download.js.map +1 -0
  99. package/dist/services/http/errors.d.ts +45 -0
  100. package/dist/services/http/errors.d.ts.map +1 -0
  101. package/dist/services/http/errors.js +188 -0
  102. package/dist/services/http/errors.js.map +1 -0
  103. package/dist/services/http/rate-limit.d.ts +40 -0
  104. package/dist/services/http/rate-limit.d.ts.map +1 -0
  105. package/dist/services/http/rate-limit.js +92 -0
  106. package/dist/services/http/rate-limit.js.map +1 -0
  107. package/dist/services/http/retry.d.ts +46 -0
  108. package/dist/services/http/retry.d.ts.map +1 -0
  109. package/dist/services/http/retry.js +101 -0
  110. package/dist/services/http/retry.js.map +1 -0
  111. package/dist/services/http/transport.d.ts +73 -0
  112. package/dist/services/http/transport.d.ts.map +1 -0
  113. package/dist/services/http/transport.js +272 -0
  114. package/dist/services/http/transport.js.map +1 -0
  115. package/dist/services/logger.d.ts +53 -2
  116. package/dist/services/logger.d.ts.map +1 -1
  117. package/dist/services/logger.js +82 -21
  118. package/dist/services/logger.js.map +1 -1
  119. package/dist/services/oauth.d.ts +12 -0
  120. package/dist/services/oauth.d.ts.map +1 -1
  121. package/dist/services/oauth.js +21 -8
  122. package/dist/services/oauth.js.map +1 -1
  123. package/dist/services/rate-limiter.d.ts +9 -2
  124. package/dist/services/rate-limiter.d.ts.map +1 -1
  125. package/dist/services/rate-limiter.js +18 -6
  126. package/dist/services/rate-limiter.js.map +1 -1
  127. package/dist/services/request-context.d.ts +47 -0
  128. package/dist/services/request-context.d.ts.map +1 -0
  129. package/dist/services/request-context.js +112 -0
  130. package/dist/services/request-context.js.map +1 -0
  131. package/dist/services/search.d.ts +26 -0
  132. package/dist/services/search.d.ts.map +1 -0
  133. package/dist/services/search.js +98 -0
  134. package/dist/services/search.js.map +1 -0
  135. package/dist/services/update-checker.d.ts.map +1 -1
  136. package/dist/services/update-checker.js +15 -8
  137. package/dist/services/update-checker.js.map +1 -1
  138. package/dist/tools/absences.d.ts +9 -0
  139. package/dist/tools/absences.d.ts.map +1 -1
  140. package/dist/tools/absences.js +65 -20
  141. package/dist/tools/absences.js.map +1 -1
  142. package/dist/tools/actions.d.ts.map +1 -1
  143. package/dist/tools/actions.js +11 -27
  144. package/dist/tools/actions.js.map +1 -1
  145. package/dist/tools/advantages.d.ts +2 -0
  146. package/dist/tools/advantages.d.ts.map +1 -1
  147. package/dist/tools/advantages.js +66 -13
  148. package/dist/tools/advantages.js.map +1 -1
  149. package/dist/tools/alerts.d.ts +18 -0
  150. package/dist/tools/alerts.d.ts.map +1 -0
  151. package/dist/tools/alerts.js +82 -0
  152. package/dist/tools/alerts.js.map +1 -0
  153. package/dist/tools/application.d.ts +2 -1
  154. package/dist/tools/application.d.ts.map +1 -1
  155. package/dist/tools/application.js +9 -3
  156. package/dist/tools/application.js.map +1 -1
  157. package/dist/tools/contacts.d.ts.map +1 -1
  158. package/dist/tools/contacts.js +3 -7
  159. package/dist/tools/contacts.js.map +1 -1
  160. package/dist/tools/contracts.d.ts +22 -0
  161. package/dist/tools/contracts.d.ts.map +1 -1
  162. package/dist/tools/contracts.js +185 -52
  163. package/dist/tools/contracts.js.map +1 -1
  164. package/dist/tools/crud-factory.d.ts +33 -2
  165. package/dist/tools/crud-factory.d.ts.map +1 -1
  166. package/dist/tools/crud-factory.js +42 -15
  167. package/dist/tools/crud-factory.js.map +1 -1
  168. package/dist/tools/deliveries.d.ts +3 -0
  169. package/dist/tools/deliveries.d.ts.map +1 -1
  170. package/dist/tools/deliveries.js +50 -94
  171. package/dist/tools/deliveries.js.map +1 -1
  172. package/dist/tools/description-builders.d.ts +0 -1
  173. package/dist/tools/description-builders.d.ts.map +1 -1
  174. package/dist/tools/description-builders.js +46 -3
  175. package/dist/tools/description-builders.js.map +1 -1
  176. package/dist/tools/documents.d.ts.map +1 -1
  177. package/dist/tools/documents.js +73 -21
  178. package/dist/tools/documents.js.map +1 -1
  179. package/dist/tools/expenses.d.ts.map +1 -1
  180. package/dist/tools/expenses.js +2 -11
  181. package/dist/tools/expenses.js.map +1 -1
  182. package/dist/tools/find.d.ts +61 -0
  183. package/dist/tools/find.d.ts.map +1 -0
  184. package/dist/tools/find.js +221 -0
  185. package/dist/tools/find.js.map +1 -0
  186. package/dist/tools/flags.d.ts +5 -0
  187. package/dist/tools/flags.d.ts.map +1 -1
  188. package/dist/tools/flags.js +116 -1
  189. package/dist/tools/flags.js.map +1 -1
  190. package/dist/tools/forms.d.ts +5 -0
  191. package/dist/tools/forms.d.ts.map +1 -0
  192. package/dist/tools/forms.js +64 -0
  193. package/dist/tools/forms.js.map +1 -0
  194. package/dist/tools/groupments.d.ts +5 -0
  195. package/dist/tools/groupments.d.ts.map +1 -0
  196. package/dist/tools/groupments.js +68 -0
  197. package/dist/tools/groupments.js.map +1 -0
  198. package/dist/tools/inactivities.d.ts +5 -0
  199. package/dist/tools/inactivities.d.ts.map +1 -0
  200. package/dist/tools/inactivities.js +59 -0
  201. package/dist/tools/inactivities.js.map +1 -0
  202. package/dist/tools/index.d.ts +4 -0
  203. package/dist/tools/index.d.ts.map +1 -1
  204. package/dist/tools/index.js +4 -0
  205. package/dist/tools/index.js.map +1 -1
  206. package/dist/tools/invoices.d.ts.map +1 -1
  207. package/dist/tools/invoices.js +27 -2
  208. package/dist/tools/invoices.js.map +1 -1
  209. package/dist/tools/linked-entity-filters.d.ts +48 -0
  210. package/dist/tools/linked-entity-filters.d.ts.map +1 -0
  211. package/dist/tools/linked-entity-filters.js +64 -0
  212. package/dist/tools/linked-entity-filters.js.map +1 -0
  213. package/dist/tools/notifications.d.ts.map +1 -1
  214. package/dist/tools/notifications.js +0 -5
  215. package/dist/tools/notifications.js.map +1 -1
  216. package/dist/tools/orders.d.ts.map +1 -1
  217. package/dist/tools/orders.js +33 -4
  218. package/dist/tools/orders.js.map +1 -1
  219. package/dist/tools/parameter-disclosure.js.map +1 -1
  220. package/dist/tools/payments.d.ts +3 -0
  221. package/dist/tools/payments.d.ts.map +1 -1
  222. package/dist/tools/payments.js +43 -109
  223. package/dist/tools/payments.js.map +1 -1
  224. package/dist/tools/positionings.d.ts.map +1 -1
  225. package/dist/tools/positionings.js +9 -39
  226. package/dist/tools/positionings.js.map +1 -1
  227. package/dist/tools/projects.d.ts.map +1 -1
  228. package/dist/tools/projects.js +5 -12
  229. package/dist/tools/projects.js.map +1 -1
  230. package/dist/tools/provider-invoices.d.ts +2 -0
  231. package/dist/tools/provider-invoices.d.ts.map +1 -1
  232. package/dist/tools/provider-invoices.js +30 -111
  233. package/dist/tools/provider-invoices.js.map +1 -1
  234. package/dist/tools/purchases.d.ts +2 -0
  235. package/dist/tools/purchases.d.ts.map +1 -1
  236. package/dist/tools/purchases.js +50 -104
  237. package/dist/tools/purchases.js.map +1 -1
  238. package/dist/tools/registration-decorators.d.ts +28 -0
  239. package/dist/tools/registration-decorators.d.ts.map +1 -1
  240. package/dist/tools/registration-decorators.js +107 -0
  241. package/dist/tools/registration-decorators.js.map +1 -1
  242. package/dist/tools/resources.d.ts.map +1 -1
  243. package/dist/tools/resources.js +27 -5
  244. package/dist/tools/resources.js.map +1 -1
  245. package/dist/tools/rights.d.ts +101 -0
  246. package/dist/tools/rights.d.ts.map +1 -0
  247. package/dist/tools/rights.js +75 -0
  248. package/dist/tools/rights.js.map +1 -0
  249. package/dist/tools/tab-tools.js +3 -3
  250. package/dist/tools/tab-tools.js.map +1 -1
  251. package/dist/tools/timesheets.d.ts +29 -0
  252. package/dist/tools/timesheets.d.ts.map +1 -1
  253. package/dist/tools/timesheets.js +196 -126
  254. package/dist/tools/timesheets.js.map +1 -1
  255. package/dist/tools/todolists.d.ts +2 -0
  256. package/dist/tools/todolists.d.ts.map +1 -1
  257. package/dist/tools/todolists.js +64 -1
  258. package/dist/tools/todolists.js.map +1 -1
  259. package/dist/tools/validations.d.ts +9 -0
  260. package/dist/tools/validations.d.ts.map +1 -1
  261. package/dist/tools/validations.js +86 -9
  262. package/dist/tools/validations.js.map +1 -1
  263. package/dist/tools/workflows.js +1 -1
  264. package/dist/tools/workflows.js.map +1 -1
  265. package/dist/transports/http.d.ts +116 -0
  266. package/dist/transports/http.d.ts.map +1 -1
  267. package/dist/transports/http.js +422 -92
  268. package/dist/transports/http.js.map +1 -1
  269. package/dist/types.d.ts +3 -3
  270. package/dist/types.d.ts.map +1 -1
  271. package/manifest.json +7 -7
  272. package/package.json +14 -10
@@ -1,1170 +1,31 @@
1
- import { createHmac } from "crypto";
2
- import { DEFAULT_BASE_URL, CHARACTER_LIMIT, DEFAULT_PAGE_SIZE, ROUTE_MAX_RESULTS, DEFAULT_MAX_RESULTS, DEFAULT_HTTP_TIMEOUT_MS, DEFAULT_HTTP_MAX_RETRIES, DEFAULT_HTTP_RETRY_BASE_MS, DEFAULT_HTTP_RETRY_MAX_MS, DEFAULT_HTTP_RATE_LIMIT_RPS, DEFAULT_HTTP_RATE_LIMIT_BURST, } from "../constants.js";
3
- import { TokenBucket } from "./rate-limiter.js";
4
- import { oauthContext } from "./oauth.js";
5
- let config = null;
6
1
  /**
7
- * Auth provider for the HTTP transport: reads the Bearer token from the
8
- * per-request AsyncLocalStorage populated by the transport layer and
9
- * forwards it verbatim to BoondManager as `Authorization: Bearer …`.
10
- *
11
- * Errors out clearly if called outside a request context — which would
12
- * indicate that the transport layer forgot to wrap the request in
13
- * `oauthContext.run(...)`.
14
- */
15
- export const oauthContextAuth = async () => {
16
- const ctx = oauthContext.getStore();
17
- if (!ctx) {
18
- throw new Error("No OAuth access token in request context. The HTTP transport requires an `Authorization: Bearer <boond_access_token>` header on every request.");
19
- }
20
- return { name: "Authorization", value: `Bearer ${ctx.accessToken}` };
21
- };
22
- function base64url(data) {
23
- const b64 = Buffer.from(data).toString("base64");
24
- return b64.replace(/=/g, "").replace(/\+/g, "-").replace(/\//g, "_");
25
- }
26
- /**
27
- * Build the BoondManager HS256 JWT. By default the payload is exactly
28
- * `{ userToken, clientToken }` (BoondManager's documented scheme). When
29
- * `expiresInSeconds` is provided, standard `iat`/`exp` claims are added so the
30
- * generated token is no longer replayable forever if it leaks — this requires
31
- * regenerating the token per request (see `jwtAuth`). Opt-in because not every
32
- * BoondManager deployment is known to honour `exp`.
33
- */
34
- export function buildJwt(userToken, clientToken, clientKey, options) {
35
- const header = base64url(JSON.stringify({ alg: "HS256", typ: "JWT" }));
36
- const claims = { userToken, clientToken };
37
- if (options?.expiresInSeconds && options.expiresInSeconds > 0) {
38
- const now = options.nowSeconds ?? Math.floor(Date.now() / 1000);
39
- claims.iat = now;
40
- claims.exp = now + options.expiresInSeconds;
41
- }
42
- const payload = base64url(JSON.stringify(claims));
43
- const signature = base64url(createHmac("sha256", clientKey).update(`${header}.${payload}`).digest());
44
- return `${header}.${payload}.${signature}`;
45
- }
46
- /**
47
- * Return the env value if it is a real user-supplied value, or undefined otherwise.
48
- *
49
- * "Real" excludes three things a config form can produce for an option the user
50
- * left alone — and the MCPB extension and the Claude Code plugin both build
51
- * their env block by substituting `${user_config.*}` into every var, so all
52
- * fourteen are always *defined*:
53
- *
54
- * - `""` — an untouched optional field;
55
- * - whitespace only — a field that got a stray space or a pasted newline
56
- * (`BOOND_BASE_URL=" "` would otherwise become the request base URL);
57
- * - `"${…}"` — a placeholder no host resolved.
58
- *
59
- * All three must read as "not configured" so the defaults apply. Same rule as
60
- * `readEnv` in `config/access-policy.ts` and `config/dictionary-overrides.ts`.
61
- */
62
- function envOrUndefined(key) {
63
- const v = process.env[key];
64
- if (!v || v.startsWith("${") || v.trim().length === 0)
65
- return undefined;
66
- return v;
67
- }
68
- export const JWT_HEADER_NAME = "X-Jwt-Client-Boondmanager";
69
- /**
70
- * Wrap a static header pair in the dynamic AuthProvider contract.
71
- * Used by the stdio transport, which sticks to the JWT / BasicAuth paths.
72
- */
73
- function staticAuth(name, value) {
74
- const cached = Promise.resolve({ name, value });
75
- return () => cached;
76
- }
77
- /**
78
- * JWT auth provider. When `ttlSeconds` is set (via BOOND_JWT_TTL_SECONDS), a
79
- * fresh token with `iat`/`exp` is minted per request so a leaked token expires;
80
- * otherwise the token is built once and cached (legacy, never-expiring).
81
- */
82
- function jwtAuth(userToken, clientToken, clientKey, ttlSeconds) {
83
- if (!ttlSeconds || ttlSeconds <= 0) {
84
- return staticAuth(JWT_HEADER_NAME, buildJwt(userToken, clientToken, clientKey));
85
- }
86
- return () => Promise.resolve({
87
- name: JWT_HEADER_NAME,
88
- value: buildJwt(userToken, clientToken, clientKey, { expiresInSeconds: ttlSeconds }),
89
- });
90
- }
91
- export function initClient() {
92
- const baseUrl = envOrUndefined("BOOND_BASE_URL") || DEFAULT_BASE_URL;
93
- // Auth priority (stdio transport):
94
- // 1. Build JWT from components (userToken + clientToken + clientKey)
95
- // 2. Pre-built JWT token
96
- // 3. BasicAuth (user:password)
97
- //
98
- // Per BoondManager's JWT spec the token must travel in the
99
- // `X-Jwt-Client-Boondmanager` header — sending it as `Authorization: Bearer`
100
- // makes the API reject the request with 422 "Signature verification failed".
101
- // BasicAuth, on the other hand, uses the standard `Authorization` header.
102
- //
103
- // HTTP transport uses OAuth2 exclusively — see `initClientWithAuth`.
104
- const userToken = envOrUndefined("BOOND_USER_TOKEN");
105
- const clientToken = envOrUndefined("BOOND_CLIENT_TOKEN");
106
- const clientKey = envOrUndefined("BOOND_CLIENT_KEY");
107
- const token = envOrUndefined("BOOND_API_TOKEN");
108
- const user = envOrUndefined("BOOND_USER");
109
- const password = envOrUndefined("BOOND_PASSWORD");
110
- let auth;
111
- if (userToken && clientToken && clientKey) {
112
- const ttlRaw = envOrUndefined("BOOND_JWT_TTL_SECONDS");
113
- const ttlSeconds = ttlRaw ? Number(ttlRaw) : undefined;
114
- auth = jwtAuth(userToken, clientToken, clientKey, Number.isFinite(ttlSeconds) ? ttlSeconds : undefined);
115
- }
116
- else if (token) {
117
- auth = staticAuth(JWT_HEADER_NAME, token);
118
- }
119
- else if (user && password) {
120
- auth = staticAuth("Authorization", `Basic ${Buffer.from(`${user}:${password}`).toString("base64")}`);
121
- }
122
- else {
123
- throw new Error("Authentication required. Set BOOND_USER_TOKEN + BOOND_CLIENT_TOKEN + BOOND_CLIENT_KEY, or BOOND_API_TOKEN, or both BOOND_USER and BOOND_PASSWORD.");
124
- }
125
- config = { baseUrl, auth };
126
- }
127
- /**
128
- * True when env-based credentials (JWT components, API token, or BasicAuth) are
129
- * configured. Used by the HTTP transport to decide whether static-auth mode is
130
- * possible without attempting a full `initClient()` call.
131
- */
132
- export function hasEnvCredentials() {
133
- return !!((envOrUndefined("BOOND_USER_TOKEN") &&
134
- envOrUndefined("BOOND_CLIENT_TOKEN") &&
135
- envOrUndefined("BOOND_CLIENT_KEY")) ||
136
- envOrUndefined("BOOND_API_TOKEN") ||
137
- (envOrUndefined("BOOND_USER") && envOrUndefined("BOOND_PASSWORD")));
138
- }
139
- /**
140
- * Install a custom auth provider — used by the HTTP transport bootstrap to
141
- * wire in an OAuth2 token source (where the access token is refreshed
142
- * transparently per request rather than baked in at startup).
143
- */
144
- export function initClientWithAuth(auth, baseUrl) {
145
- config = {
146
- baseUrl: baseUrl ?? envOrUndefined("BOOND_BASE_URL") ?? DEFAULT_BASE_URL,
147
- auth,
148
- };
149
- }
150
- /** Test helper — reset the cached config so the next call re-initialises. */
151
- export function resetClientForTests() {
152
- config = null;
153
- }
154
- function getConfig() {
155
- if (!config) {
156
- initClient();
157
- }
158
- return config;
159
- }
160
- /**
161
- * Pull the human-readable bits out of a BoondManager error body.
162
- *
163
- * Boond returns JSON:API errors of the form:
164
- * { "errors": [ { "status": "422", "code": "422", "detail": "...", "title": "..." } ] }
165
- *
166
- * Surfacing `detail` (and `title` when present) gives the model a focused
167
- * message like `422 - password mismatch` instead of the full ~500-char body
168
- * dump that previously made it hard for the LLM to reason about the failure.
169
- *
170
- * Exported for unit testing.
171
- */
172
- export function parseBoondErrorBody(body) {
173
- if (!body)
174
- return null;
175
- try {
176
- const parsed = JSON.parse(body);
177
- const errors = Array.isArray(parsed.errors) ? parsed.errors : [];
178
- const messages = errors
179
- .map((e) => {
180
- const parts = [];
181
- if (e.title && e.title !== e.detail)
182
- parts.push(e.title);
183
- if (e.detail)
184
- parts.push(e.detail);
185
- else if (e.code)
186
- parts.push(`code ${e.code}`);
187
- // Boond's JSON:API errors put the offending query/body field in
188
- // source.parameter (or source.pointer). Surfacing it turns the
189
- // otherwise-opaque "1017 - Missing required attribute" into
190
- // "1017 - Missing required attribute (parameter: startMonth)".
191
- const ref = e.source?.parameter ?? e.source?.pointer;
192
- const head = parts.join(": ").trim();
193
- if (!head)
194
- return ref ? `parameter: ${ref}` : "";
195
- return ref ? `${head} (parameter: ${ref})` : head;
196
- })
197
- .filter((m) => m.length > 0);
198
- if (messages.length === 0)
199
- return null;
200
- return messages.join(" | ");
201
- }
202
- catch {
203
- return null;
204
- }
205
- }
206
- /**
207
- * Resolve the per-request HTTP timeout in milliseconds.
208
- *
209
- * Reads BOOND_HTTP_TIMEOUT_MS at call time so tests / runtime overrides take
210
- * effect without restarting the process. Falls back to the default for
211
- * unset, non-numeric, or non-positive values.
212
- *
213
- * Exported for unit testing.
214
- */
215
- export function resolveTimeoutMs() {
216
- const raw = envOrUndefined("BOOND_HTTP_TIMEOUT_MS");
217
- if (!raw)
218
- return DEFAULT_HTTP_TIMEOUT_MS;
219
- const parsed = Number(raw);
220
- if (!Number.isFinite(parsed) || parsed <= 0)
221
- return DEFAULT_HTTP_TIMEOUT_MS;
222
- return Math.floor(parsed);
223
- }
224
- /** True when an error from fetch() came from an AbortSignal firing. */
225
- function isAbortError(err) {
226
- if (!(err instanceof Error))
227
- return false;
228
- // AbortSignal.timeout() rejects with a DOMException whose name is "TimeoutError";
229
- // generic aborts surface as "AbortError". Both indicate the request never
230
- // completed end-to-end and should be reported as a timeout.
231
- return err.name === "TimeoutError" || err.name === "AbortError";
232
- }
233
- function readPositiveInt(name, fallback, allowZero = false) {
234
- const raw = envOrUndefined(name);
235
- if (!raw)
236
- return fallback;
237
- const parsed = Number(raw);
238
- if (!Number.isFinite(parsed))
239
- return fallback;
240
- if (parsed < 0)
241
- return fallback;
242
- if (parsed === 0 && !allowZero)
243
- return fallback;
244
- return Math.floor(parsed);
245
- }
246
- /** Resolve retry configuration from env, with safe fallbacks. Exported for tests. */
247
- export function resolveRetryConfig() {
248
- return {
249
- maxRetries: readPositiveInt("BOOND_HTTP_MAX_RETRIES", DEFAULT_HTTP_MAX_RETRIES, true),
250
- baseDelayMs: readPositiveInt("BOOND_HTTP_RETRY_BASE_MS", DEFAULT_HTTP_RETRY_BASE_MS),
251
- maxDelayMs: readPositiveInt("BOOND_HTTP_RETRY_MAX_MS", DEFAULT_HTTP_RETRY_MAX_MS),
252
- };
253
- }
254
- /**
255
- * Decide whether a failed attempt is worth retrying.
256
- *
257
- * Retry policy is intentionally conservative for non-idempotent verbs to avoid
258
- * silently duplicating writes when the server's response was lost or delayed:
259
- * - 429 (Too Many Requests) is always retried — the server explicitly
260
- * rejected the request before processing it, so it is safe regardless of
261
- * verb.
262
- * - For GET only, 5xx responses, network failures, and timeouts are retried
263
- * because GET is idempotent.
264
- * - 4xx responses (other than 429) are never retried — the client must change
265
- * the request before another attempt makes sense.
266
- *
267
- * Exported for unit testing.
268
- */
269
- export function isRetryable(method, status, isNetworkOrTimeout) {
270
- if (status === 429)
271
- return true;
272
- if (method !== "GET")
273
- return false;
274
- if (isNetworkOrTimeout)
275
- return true;
276
- if (status !== undefined && status >= 500 && status < 600)
277
- return true;
278
- return false;
279
- }
280
- /**
281
- * Parse a `Retry-After` header value into milliseconds.
282
- *
283
- * Accepts either a non-negative number of seconds or an HTTP-date. Returns
284
- * null when the value is absent or unparseable. Negative computed delays are
285
- * clamped to 0. Exported for unit testing.
286
- */
287
- export function parseRetryAfter(value, now = Date.now()) {
288
- if (!value)
289
- return null;
290
- const trimmed = value.trim();
291
- if (trimmed === "")
292
- return null;
293
- const seconds = Number(trimmed);
294
- if (Number.isFinite(seconds)) {
295
- // Numeric form is authoritative once we recognise it as a number — falling
296
- // through to Date.parse on a negative/odd numeric would silently produce
297
- // weird timestamps (e.g. Date.parse("-1") → year -1).
298
- return seconds >= 0 ? Math.floor(seconds * 1000) : null;
299
- }
300
- const date = Date.parse(trimmed);
301
- if (!Number.isNaN(date))
302
- return Math.max(0, date - now);
303
- return null;
304
- }
305
- /**
306
- * Compute the next backoff delay using full jitter:
307
- * delay = random(0, min(maxMs, baseMs * 2^attempt))
308
- *
309
- * Full jitter (vs. exponential-only) reduces thundering-herd risk when many
310
- * clients retry in lockstep. Exported for unit testing.
311
- */
312
- export function computeBackoffMs(attempt, baseMs, maxMs, random = Math.random) {
313
- const exp = baseMs * 2 ** attempt;
314
- const capped = Math.min(maxMs, exp);
315
- return Math.floor(random() * capped);
316
- }
317
- function sleep(ms) {
318
- if (ms <= 0)
319
- return Promise.resolve();
320
- return new Promise((resolve) => setTimeout(resolve, ms));
321
- }
322
- /**
323
- * Read rate-limit env vars. `rps` of 0 (or non-numeric) disables rate
324
- * limiting entirely. `burst` falls back to `rps * 2` when unset, mirroring
325
- * the documented default behaviour. Exported for unit testing.
326
- */
327
- export function resolveRateLimitConfig() {
328
- const rpsRaw = envOrUndefined("BOOND_HTTP_RATE_LIMIT_RPS");
329
- const rps = rpsRaw === undefined ? DEFAULT_HTTP_RATE_LIMIT_RPS : Number(rpsRaw);
330
- if (!Number.isFinite(rps) || rps <= 0)
331
- return null;
332
- const burstRaw = envOrUndefined("BOOND_HTTP_RATE_LIMIT_BURST");
333
- let burst;
334
- if (burstRaw === undefined) {
335
- burst = rpsRaw === undefined ? DEFAULT_HTTP_RATE_LIMIT_BURST : Math.max(1, Math.ceil(rps));
336
- }
337
- else {
338
- const parsed = Number(burstRaw);
339
- burst = Number.isFinite(parsed) && parsed >= 1 ? Math.floor(parsed) : Math.max(1, Math.ceil(rps));
340
- }
341
- return { rps, burst };
342
- }
343
- let rateLimiter = null;
344
- let rateLimiterInitialised = false;
345
- function getRateLimiter() {
346
- if (rateLimiterInitialised)
347
- return rateLimiter;
348
- const config = resolveRateLimitConfig();
349
- rateLimiter = config ? new TokenBucket(config.burst, config.rps) : null;
350
- rateLimiterInitialised = true;
351
- return rateLimiter;
352
- }
353
- /**
354
- * Reset the cached rate limiter so the next request re-reads env vars.
355
- * Intended for tests that toggle `BOOND_HTTP_RATE_LIMIT_*` between cases.
356
- */
357
- export function resetRateLimiterForTests() {
358
- rateLimiter = null;
359
- rateLimiterInitialised = false;
360
- }
361
- /** Status-specific hint to help the LLM (or human) recover from common failures. */
362
- function hintForStatus(status) {
363
- switch (status) {
364
- case 400:
365
- return "Check the request body or query parameters — likely a malformed field.";
366
- case 401:
367
- return "Authentication failed. Verify BOOND_USER_TOKEN + BOOND_CLIENT_TOKEN + BOOND_CLIENT_KEY (or BOOND_API_TOKEN, or BOOND_USER + BOOND_PASSWORD). On HTTP transport, the OAuth access token may have expired — re-run boondmanager-mcp-oauth-login.";
368
- case 403:
369
- return "Authenticated, but the user lacks permission for this endpoint or scope.";
370
- case 404:
371
- return "Endpoint or entity not found. Double-check the id and the API path.";
372
- case 422:
373
- return "Unprocessable: typically wrong credentials (the API returns 422 for password mismatch) or a query parameter the API rejects.";
374
- case 429:
375
- return "Rate-limited. Back off and retry after a few seconds.";
376
- default:
377
- if (status >= 500)
378
- return "BoondManager-side error. Retrying after a short delay usually helps.";
379
- return "Check your credentials and permissions for this endpoint.";
380
- }
381
- }
382
- /**
383
- * Detect whether the response body looks like a Cloudflare WAF challenge or
384
- * block page rather than a BoondManager JSON:API response. When this is true,
385
- * the upstream service is unreachable and the JSON:API hint above is
386
- * misleading — the request never reached BoondManager.
387
- */
388
- function containsCloudflareChallengeHost(htmlSnippet) {
389
- const urlMatches = htmlSnippet.match(/https?:\/\/[^\s"'<>]+/gi) ?? [];
390
- for (const rawUrl of urlMatches) {
391
- try {
392
- const hostname = new URL(rawUrl).hostname.toLowerCase();
393
- if (hostname === "challenges.cloudflare.com" || hostname.endsWith(".challenges.cloudflare.com")) {
394
- return true;
395
- }
396
- }
397
- catch {
398
- // Ignore unparsable URL fragments in HTML.
399
- }
400
- }
401
- return false;
402
- }
403
- function looksLikeCloudflareBlock(body) {
404
- if (!body)
405
- return false;
406
- const head = body.slice(0, 1000).toLowerCase();
407
- if (!head.includes("<!doctype html") && !head.includes("<html"))
408
- return false;
409
- return (head.includes("cloudflare") ||
410
- head.includes("attention required") ||
411
- head.includes("just a moment") ||
412
- head.includes("cf-ray") ||
413
- containsCloudflareChallengeHost(head));
414
- }
415
- /** Build the Error message for a non-2xx HTTP response. Exported for testing. */
416
- export function formatApiError(status, statusText, method, path, body) {
417
- const detail = parseBoondErrorBody(body);
418
- const cloudflareBlocked = looksLikeCloudflareBlock(body);
419
- const headline = cloudflareBlocked
420
- ? `BoondManager API ${status} ${statusText} — request blocked by Cloudflare WAF before reaching the API`
421
- : detail
422
- ? `BoondManager API ${status} ${statusText}: ${detail}`
423
- : `BoondManager API ${status} ${statusText}`;
424
- const lines = [headline, `Endpoint: ${method} ${path}`];
425
- // Only attach the raw body when we couldn't extract a structured detail
426
- // and we don't already know it's a Cloudflare HTML page — in either case
427
- // the raw HTML/error chunk just buries the useful message.
428
- if (!detail && !cloudflareBlocked && body) {
429
- const trimmed = body.length > 500 ? body.slice(0, 500) + "…" : body;
430
- lines.push(`Body: ${trimmed}`);
431
- }
432
- if (cloudflareBlocked) {
433
- lines.push("Hint: The BoondManager edge (Cloudflare) blocked this request. " +
434
- "This often means the endpoint is restricted on this tenant, or you've made too many calls in a short window. " +
435
- "Wait a few seconds and retry; if it persists, the endpoint is not enabled for this account.");
436
- }
437
- else {
438
- lines.push(`Hint: ${hintForStatus(status)}`);
439
- }
440
- return lines.join("\n");
441
- }
442
- /**
443
- * Defense-in-depth against path traversal / query injection through entity
444
- * ids interpolated into API paths at ~40 call sites. Even though the id
445
- * schemas are now numeric-only, a future tool could forget to validate, so we
446
- * assert here that the path is well-formed: it must start with `/`, carry no
447
- * query (`?`) or fragment (`#`) — those arrive via `queryParams`, never the
448
- * path — and contain no traversal (`..`) or percent/backslash escapes. Built
449
- * paths only ever combine static segments with numeric ids and hyphenated tab
450
- * names, so this rejects nothing legitimate. Exported for unit testing.
451
- */
452
- export function assertSafeApiPath(path) {
453
- if (!path.startsWith("/")) {
454
- throw new Error(`Invalid API path (must start with "/"): ${path}`);
455
- }
456
- // `?`/`#` would inject a query/fragment; `%`/`\` could encode a traversal;
457
- // `..` is a literal traversal segment.
458
- if (/[?#%\\]/.test(path) || path.includes("..")) {
459
- throw new Error(`Unsafe API path rejected: ${path}`);
460
- }
461
- }
462
- /**
463
- * Validates `path` and resolves it against `baseUrl`, returning the
464
- * constructed URL. Throws if the path is unsafe (see `assertSafeApiPath`) or
465
- * if the resolved URL escapes the configured API base origin/path. Centralises
466
- * the guard shared by apiRequest / apiDownload / apiUploadForm. Exported for
467
- * unit testing.
468
- */
469
- export function resolveApiUrl(baseUrl, path) {
470
- assertSafeApiPath(path);
471
- const url = new URL(`${baseUrl}${path}`);
472
- // Belt-and-braces: confirm the constructed URL did not escape the API base
473
- // origin/path despite the textual guard above.
474
- const base = new URL(baseUrl);
475
- if (url.origin !== base.origin || !url.pathname.startsWith(base.pathname)) {
476
- throw new Error(`API path escaped the configured base URL: ${path}`);
477
- }
478
- return url;
479
- }
480
- export async function apiRequest(path, method = "GET", body, queryParams) {
481
- const { baseUrl, auth } = getConfig();
482
- const url = resolveApiUrl(baseUrl, path);
483
- if (queryParams) {
484
- for (const [key, value] of Object.entries(queryParams)) {
485
- if (value === undefined || value === null)
486
- continue;
487
- if (Array.isArray(value)) {
488
- // BoondManager expects repeated bracket notation: key[]=v1&key[]=v2
489
- const bracketKey = key.endsWith("[]") ? key : `${key}[]`;
490
- for (const v of value) {
491
- if (v !== undefined && v !== null && v !== "") {
492
- url.searchParams.append(bracketKey, String(v));
493
- }
494
- }
495
- }
496
- else {
497
- url.searchParams.set(key, String(value));
498
- }
499
- }
500
- }
501
- const timeoutMs = resolveTimeoutMs();
502
- const retry = resolveRetryConfig();
503
- const totalAttempts = retry.maxRetries + 1;
504
- const buildBody = () => body && (method === "POST" || method === "PUT" || method === "PATCH") ? JSON.stringify(body) : undefined;
505
- const serializedBody = buildBody();
506
- let lastError;
507
- const limiter = getRateLimiter();
508
- for (let attempt = 0; attempt < totalAttempts; attempt++) {
509
- // Acquire a token before each attempt so retries also count toward the
510
- // rate budget — this is what actually protects us from feedback loops
511
- // (transient 5xx → retry → transient 5xx → …) saturating the API.
512
- if (limiter)
513
- await limiter.acquire();
514
- // Resolve the auth header per-attempt so OAuth2 refreshes are picked
515
- // up between retries (the access token may have expired since the
516
- // previous attempt).
517
- const authHeader = await auth();
518
- const headers = {
519
- [authHeader.name]: authHeader.value,
520
- Accept: "application/json",
521
- "Content-Type": "application/json",
522
- };
523
- const fetchOptions = {
524
- method,
525
- headers,
526
- // Each attempt gets its own abort signal — once a signal has fired it
527
- // can't be reused for the next try.
528
- signal: AbortSignal.timeout(timeoutMs),
529
- };
530
- if (serializedBody !== undefined) {
531
- fetchOptions.body = serializedBody;
532
- }
533
- let response;
534
- let networkError;
535
- try {
536
- response = await fetch(url.toString(), fetchOptions);
537
- }
538
- catch (err) {
539
- if (isAbortError(err)) {
540
- networkError = new Error([
541
- `BoondManager API request timed out after ${timeoutMs}ms`,
542
- `Endpoint: ${method} ${path}`,
543
- "Hint: Increase BOOND_HTTP_TIMEOUT_MS or check connectivity to the BoondManager API.",
544
- ].join("\n"), { cause: err });
545
- }
546
- else {
547
- networkError = err instanceof Error ? err : new Error(String(err));
548
- }
549
- }
550
- if (response && response.ok) {
551
- // DELETE may return empty body
552
- if (response.status === 204 || response.headers.get("content-length") === "0") {
553
- return { data: [] };
554
- }
555
- return (await response.json());
556
- }
557
- let attemptError;
558
- let retryAfterMs = null;
559
- let isNetworkOrTimeout = false;
560
- if (response) {
561
- const errorText = await response.text().catch(() => "");
562
- attemptError = new Error(formatApiError(response.status, response.statusText, method, path, errorText));
563
- }
564
- else {
565
- attemptError = networkError;
566
- isNetworkOrTimeout = true;
567
- }
568
- const hasMoreAttempts = attempt < totalAttempts - 1;
569
- const retryable = isRetryable(method, response?.status, isNetworkOrTimeout);
570
- if (!hasMoreAttempts || !retryable) {
571
- throw attemptError;
572
- }
573
- // Only inspect Retry-After when we've actually decided to retry — keeps
574
- // the fast path off the headers object and matches existing tests that
575
- // build minimal Response stubs.
576
- if (response) {
577
- retryAfterMs = parseRetryAfter(response.headers?.get("retry-after") ?? null);
578
- }
579
- const backoff = retryAfterMs !== null
580
- ? Math.min(retry.maxDelayMs, retryAfterMs)
581
- : computeBackoffMs(attempt, retry.baseDelayMs, retry.maxDelayMs);
582
- await sleep(backoff);
583
- lastError = attemptError;
584
- }
585
- // Defensive — the loop always returns or throws. If somehow exhausted:
586
- throw lastError ?? new Error("BoondManager API request exhausted retries with no recorded error.");
587
- }
588
- /**
589
- * Parse the filename out of a `Content-Disposition` header. Handles the
590
- * common `filename="…"`/`filename=…` forms and the RFC 5987
591
- * `filename*=UTF-8''…` form. Returns undefined when absent. Exported for
592
- * unit testing.
593
- */
594
- export function parseContentDispositionFilename(header) {
595
- if (!header)
596
- return undefined;
597
- const star = header.match(/filename\*\s*=\s*(?:UTF-8|utf-8)''([^;]+)/);
598
- if (star) {
599
- try {
600
- return decodeURIComponent(star[1].trim());
601
- }
602
- catch {
603
- // fall through to the plain form
604
- }
605
- }
606
- const plain = header.match(/filename\s*=\s*"([^"]+)"/) ?? header.match(/filename\s*=\s*([^;]+)/);
607
- return plain ? plain[1].trim() : undefined;
608
- }
609
- /** Human-readable byte count for progress messages (same units as the tool output). */
610
- function formatBytes(bytes) {
611
- return bytes >= 1024 * 1024 ? `${(bytes / 1024 / 1024).toFixed(1)} Mo` : `${Math.round(bytes / 1024)} Ko`;
612
- }
613
- /** Progress steps emitted while streaming a download (≈ every 10 %). */
614
- const DOWNLOAD_PROGRESS_STEPS = 10;
615
- /**
616
- * Read a download body, reporting bytes received as it goes.
617
- *
618
- * The streaming path only runs when someone is actually listening **and** the
619
- * response announced a `Content-Length` — without a total there is nothing
620
- * meaningful to report, and buffering through `arrayBuffer()` is both simpler
621
- * and faster. So the default path is byte-for-byte the previous behaviour.
622
- */
623
- async function readDownloadBody(response, onProgress) {
624
- const totalBytes = Number(response.headers.get("content-length"));
625
- const body = response.body;
626
- if (!onProgress?.enabled || !body || !Number.isFinite(totalBytes) || totalBytes <= 0) {
627
- return Buffer.from(await response.arrayBuffer());
628
- }
629
- const reader = body.getReader();
630
- const step = Math.max(1, Math.floor(totalBytes / DOWNLOAD_PROGRESS_STEPS));
631
- const chunks = [];
632
- let received = 0;
633
- let reported = 0;
634
- for (;;) {
635
- const { done, value } = await reader.read();
636
- if (done)
637
- break;
638
- if (!value)
639
- continue;
640
- chunks.push(value);
641
- received += value.byteLength;
642
- // Throttled to ~10 notifications: a 5 MiB file arrives in ~80 network
643
- // chunks, and one notification each would be its own kind of flood.
644
- if (received - reported >= step) {
645
- reported = received;
646
- onProgress(received, totalBytes, `Téléchargement — ${formatBytes(received)} / ${formatBytes(totalBytes)}`);
647
- }
648
- }
649
- if (received > reported) {
650
- onProgress(received, totalBytes, `Téléchargement terminé — ${formatBytes(received)}`);
651
- }
652
- return Buffer.concat(chunks);
653
- }
654
- /**
655
- * Download a binary payload (documents, justificatifs…) from the BoondManager
656
- * API. Same auth/safety/rate-limit plumbing as `apiRequest`, but the body is
657
- * returned raw instead of being parsed as JSON:API. Single attempt: document
658
- * downloads are interactive one-offs, not worth a retry loop.
659
- *
660
- * `onProgress` reports bytes received when the client asked for progress and
661
- * the response carries a `Content-Length`; otherwise nothing is emitted.
662
- */
663
- export async function apiDownload(path, onProgress) {
664
- const { baseUrl, auth } = getConfig();
665
- const url = resolveApiUrl(baseUrl, path);
666
- const limiter = getRateLimiter();
667
- if (limiter)
668
- await limiter.acquire();
669
- const authHeader = await auth();
670
- const timeoutMs = resolveTimeoutMs();
671
- let response;
672
- try {
673
- response = await fetch(url.toString(), {
674
- method: "GET",
675
- headers: { [authHeader.name]: authHeader.value, Accept: "*/*" },
676
- signal: AbortSignal.timeout(timeoutMs),
677
- });
678
- }
679
- catch (err) {
680
- if (isAbortError(err)) {
681
- throw new Error([
682
- `BoondManager API request timed out after ${timeoutMs}ms`,
683
- `Endpoint: GET ${path}`,
684
- "Hint: Increase BOOND_HTTP_TIMEOUT_MS or check connectivity to the BoondManager API.",
685
- ].join("\n"), { cause: err });
686
- }
687
- throw err instanceof Error ? err : new Error(String(err));
688
- }
689
- if (!response.ok) {
690
- const errorText = await response.text().catch(() => "");
691
- throw new Error(formatApiError(response.status, response.statusText, "GET", path, errorText));
692
- }
693
- const contentType = response.headers.get("content-type")?.split(";")[0].trim() || "application/octet-stream";
694
- const filename = parseContentDispositionFilename(response.headers.get("content-disposition"));
695
- // BoondManager only answers 404 on an unknown document when the request asks
696
- // for JSON. With the `Accept: */*` this function sends, it serves the
697
- // application shell instead — HTTP 200, `text/html`, ~9 KB — which the caller
698
- // would happily surface as the document's text content. A truncated id then
699
- // looks like a corrupted file rather than a wrong id, so refuse the shell
700
- // here. An HTML *document* is still downloadable: a real file download
701
- // carries a `Content-Disposition` filename, the shell doesn't.
702
- if (contentType === "text/html" && !filename) {
703
- throw new Error([
704
- "BoondManager returned an HTML page instead of a document (HTTP 200, text/html).",
705
- `Endpoint: GET ${path}`,
706
- "Hint: The document id is most likely wrong or truncated. Entity relations expose suffixed ids " +
707
- "(e.g. `123_resume`, `123_file`) — pass the id verbatim, suffix included. BoondManager serves its " +
708
- "application shell for an unknown /documents/<id> instead of a 404.",
709
- ].join("\n"));
710
- }
711
- const data = await readDownloadBody(response, onProgress);
712
- return { data, contentType, filename };
713
- }
714
- /**
715
- * POST a multipart/form-data payload to the BoondManager API (document
716
- * upload). Form values are simple string fields — the file itself travels by
717
- * reference via the `fileUrl` field (Boond downloads it server-side), so the
718
- * MCP server never buffers file bytes.
719
- */
720
- export async function apiUploadForm(path, fields) {
721
- const { baseUrl, auth } = getConfig();
722
- const url = resolveApiUrl(baseUrl, path);
723
- const limiter = getRateLimiter();
724
- if (limiter)
725
- await limiter.acquire();
726
- const form = new FormData();
727
- for (const [key, value] of Object.entries(fields)) {
728
- form.set(key, value);
729
- }
730
- const authHeader = await auth();
731
- // No Content-Type header: fetch derives the multipart boundary from FormData.
732
- const response = await fetch(url.toString(), {
733
- method: "POST",
734
- headers: { [authHeader.name]: authHeader.value, Accept: "application/json" },
735
- body: form,
736
- signal: AbortSignal.timeout(resolveTimeoutMs()),
737
- });
738
- if (!response.ok) {
739
- const errorText = await response.text().catch(() => "");
740
- throw new Error(formatApiError(response.status, response.statusText, "POST", path, errorText));
741
- }
742
- if (response.status === 204 || response.headers.get("content-length") === "0") {
743
- return { data: [] };
744
- }
745
- return (await response.json());
746
- }
747
- export function buildSearchQuery(params) {
748
- const query = {};
749
- if (params.keywords)
750
- query["keywords"] = params.keywords;
751
- if (params.page !== undefined)
752
- query["page"] = params.page;
753
- if (params.pageSize !== undefined)
754
- query["maxResults"] = params.pageSize;
755
- // Forward any additional filter params (strings, numbers, or arrays).
756
- // `fields` is a client-side projection consumed by formatListResponse,
757
- // never a BoondManager query parameter.
758
- for (const [key, value] of Object.entries(params)) {
759
- if (["keywords", "page", "pageSize", "fields"].includes(key))
760
- continue;
761
- if (value === undefined || value === null)
762
- continue;
763
- if (Array.isArray(value)) {
764
- // Pass arrays through so apiRequest emits repeated bracket notation
765
- query[key] = value;
766
- }
767
- else if (typeof value === "string" || typeof value === "number") {
768
- query[key] = value;
769
- }
770
- else {
771
- query[key] = String(value);
772
- }
773
- }
774
- return query;
775
- }
776
- /**
777
- * Search wrapper around `apiRequest` that enforces BoondManager's per-route
778
- * `maxResults` ceiling (see `ROUTE_MAX_RESULTS`). When the caller requests more
779
- * results than the route allows, the request is transparently split into
780
- * chunks of `cap` records and the pages are merged into a single JSON:API
781
- * response — the caller still receives the full page, but BoondManager never
782
- * sees `maxResults` above the cap (which overflows memory on `/actions`).
783
- *
784
- * Routes whose ceiling already covers the requested page size take the fast
785
- * path: a single `apiRequest`, byte-for-byte identical to calling it directly.
786
- * The chunk count is bounded by `ceil((offset + requested) / cap)`, so there is
787
- * no unbounded loop; the loop also stops early once a page comes back short
788
- * (end of the result set on the server).
789
- *
790
- * `onProgress` (optional, last position — no existing caller had to change) is
791
- * invoked **only on the chunked path**: one step per BoondManager page. The
792
- * fast path stays silent on purpose — a single API call has nothing to report
793
- * and a "1/1" notification would be pure noise. The reporter is a no-op unless
794
- * the client sent a `progressToken` (see `services/progress.ts`).
795
- */
796
- export async function apiSearch(path, query, onProgress) {
797
- const cap = ROUTE_MAX_RESULTS[path] ?? DEFAULT_MAX_RESULTS;
798
- const requested = typeof query["maxResults"] === "number" ? query["maxResults"] : DEFAULT_PAGE_SIZE;
799
- const page = typeof query["page"] === "number" ? query["page"] : 1;
800
- // Fast path: one call, maxResults left exactly as the caller built it.
801
- if (requested <= cap) {
802
- return apiRequest(path, "GET", undefined, query);
803
- }
804
- // Chunked path: fetch `requested` records starting at the absolute offset
805
- // implied by (page, requested), in BoondManager pages of `cap` records.
806
- const startRow = (page - 1) * requested;
807
- const firstBoondPage = Math.floor(startRow / cap) + 1;
808
- const offsetInFirstChunk = startRow % cap;
809
- const needed = offsetInFirstChunk + requested;
810
- const collected = [];
811
- let meta;
812
- // Upper bound of the loop, and the `total` advertised to the client. It stays
813
- // constant across the notifications of one call, as the spec requires.
814
- const totalChunks = Math.ceil(needed / cap);
815
- let fetchedChunks = 0;
816
- for (let i = 0; collected.length < needed; i++) {
817
- const chunkQuery = { ...query, page: firstBoondPage + i, maxResults: cap };
818
- const response = await apiRequest(path, "GET", undefined, chunkQuery);
819
- if (meta === undefined)
820
- meta = response.meta;
821
- const chunk = Array.isArray(response.data) ? response.data : response.data ? [response.data] : [];
822
- collected.push(...chunk);
823
- fetchedChunks = i + 1;
824
- onProgress?.(fetchedChunks, totalChunks, `Récupération ${path} — page ${fetchedChunks}/${totalChunks}`);
825
- // A short page means there is no more data on the server — stop early.
826
- if (chunk.length < cap)
827
- break;
828
- }
829
- const data = collected.slice(offsetInFirstChunk, offsetInFirstChunk + requested);
830
- // Early stop (result set exhausted): close the bar rather than leaving the
831
- // client at 2/5 forever. Skipped when the last page already reported `total`,
832
- // which would repeat a value instead of increasing it.
833
- if (fetchedChunks < totalChunks) {
834
- onProgress?.(totalChunks, totalChunks, `Récupération ${path} — terminé (${data.length} résultat(s))`);
835
- }
836
- return meta !== undefined ? { data, meta } : { data };
837
- }
838
- /**
839
- * Business identifiers used as a last resort when a list row has no
840
- * human-readable identity (no name, no title, no dictionary `value`).
841
- *
842
- * Transactional endpoints (`/invoices`, `/orders`, `/actions`,
843
- * `/deliveries-groupments`, `/projects`…) key their rows on a reference, a
844
- * number or a date rather than on a name, so the standard summary rendered
845
- * them as a bare `[order #1234] | Statut: 1` — a line the model cannot act on
846
- * without a follow-up `_get` per row.
847
- *
848
- * These are deliberately NOT appended unconditionally: `/resources` and
849
- * `/opportunities` also carry `reference` and amount attributes, and their
850
- * rows already read well (name, title). Enriching them too would only inflate
851
- * every line. See `hasIdentity` in formatEntitySummary.
852
- */
853
- const AMOUNT_FALLBACK_FIELDS = [
854
- ["turnoverInvoicedExcludingTax", "CA facturé HT"],
855
- ["turnoverOrderedExcludingTax", "CA commandé HT"],
856
- ["turnoverSimulatedExcludingTax", "CA simulé HT"],
857
- ["averageDailyPriceExcludingTax", "TJM HT"],
858
- ];
859
- /** Max amount entries appended to a fallback line, to keep it scannable. */
860
- const MAX_FALLBACK_AMOUNTS = 2;
861
- /** Max length (in code points) of the `text` excerpt used to identify an action. */
862
- const MAX_TEXT_EXCERPT = 80;
863
- /**
864
- * HTML comments, then element tags. The tag pattern requires a tag name right
865
- * after the `<` (or `</`), so free text such as
866
- * `Relancer si < 3 jours > sinon cloturer` survives intact — a naive
867
- * `/<[^>]*>/` swallowed everything between the two operators. Quoted attribute
868
- * values are matched explicitly so a `>` inside one (`<a href="a>b">`) doesn't
869
- * end the tag early and leak `b">` into the excerpt. The alternatives are
870
- * mutually exclusive on their first character, so there is no backtracking
871
- * blow-up on unterminated input.
872
- */
873
- const HTML_COMMENT_RE = /<!--[\s\S]*?-->/g;
874
- const HTML_TAG_RE = /<\/?[a-zA-Z][a-zA-Z0-9:._-]*(?:\s+(?:"[^"]*"|'[^']*'|[^"'<>])*)?\/?>/g;
875
- /** Entities actually seen in BoondManager notes (WYSIWYG output + French text). */
876
- const NAMED_ENTITIES = {
877
- amp: "&",
878
- lt: "<",
879
- gt: ">",
880
- quot: '"',
881
- apos: "'",
882
- nbsp: " ",
883
- hellip: "…",
884
- agrave: "à",
885
- acirc: "â",
886
- ccedil: "ç",
887
- eacute: "é",
888
- egrave: "è",
889
- ecirc: "ê",
890
- euml: "ë",
891
- icirc: "î",
892
- iuml: "ï",
893
- ocirc: "ô",
894
- ugrave: "ù",
895
- ucirc: "û",
896
- uuml: "ü",
897
- laquo: "«",
898
- raquo: "»",
899
- rsquo: "’",
900
- lsquo: "‘",
901
- ldquo: "“",
902
- rdquo: "”",
903
- deg: "°",
904
- euro: "€",
905
- ndash: "–",
906
- mdash: "—",
907
- };
908
- /** Decodes numeric and common named entities so the excerpt reads as text, not as markup. */
909
- function decodeHtmlEntities(input) {
910
- return input.replace(/&(#[0-9]+|#[xX][0-9a-fA-F]+|[a-zA-Z][a-zA-Z0-9]*);/g, (match, body) => {
911
- if (body.startsWith("#")) {
912
- const hex = body[1] === "x" || body[1] === "X";
913
- const code = Number.parseInt(hex ? body.slice(2) : body.slice(1), hex ? 16 : 10);
914
- // Surrogate code points are rejected on purpose: decoding `&#55296;`
915
- // would inject the very unpaired surrogate the excerpt guards against.
916
- if (!Number.isInteger(code) || code <= 0 || code > 0x10ffff)
917
- return match;
918
- if (code >= 0xd800 && code <= 0xdfff)
919
- return match;
920
- return String.fromCodePoint(code);
921
- }
922
- return NAMED_ENTITIES[body.toLowerCase()] ?? match;
923
- });
924
- }
925
- /**
926
- * Renders BoondManager's HTML note fields (`/actions`.text is a `<div>…</div>`)
927
- * as a short single-line excerpt. Only strings are excerpted — `text: null` and
928
- * nested objects are skipped by the caller rather than printed as `null` /
929
- * `[object Object]`.
930
- *
931
- * Truncation runs on code points (`Array.from`), never on UTF-16 code units, so
932
- * an emoji sitting on the boundary can't be cut into an unpaired surrogate.
933
- */
934
- function textExcerpt(raw) {
935
- const stripped = decodeHtmlEntities(raw.replace(HTML_COMMENT_RE, " ").replace(HTML_TAG_RE, " "))
936
- .replace(/\s+/g, " ")
937
- .trim();
938
- if (stripped === "")
939
- return undefined;
940
- const chars = Array.from(stripped);
941
- return chars.length > MAX_TEXT_EXCERPT ? `${chars.slice(0, MAX_TEXT_EXCERPT).join("")}…` : stripped;
942
- }
943
- /**
944
- * Single rendering rule for a raw JSON:API attribute value, shared by the
945
- * fallback summary and the `fields` projection — some Boond amounts come back
946
- * as `{ amount, currency }` objects, and the two paths used to disagree
947
- * (`[object Object]` on one side, JSON on the other).
948
- */
949
- function renderAttributeValue(value) {
950
- return value === null || typeof value === "object" ? JSON.stringify(value) : String(value);
951
- }
952
- /**
953
- * A row is considered to name itself through `value` only when that value is a
954
- * non-empty string once rendered. `value: null` / `value: ""` used to both
955
- * print a bogus token *and* suppress the business-identifier fallback.
956
- */
957
- function hasValueIdentity(value) {
958
- return value !== undefined && value !== null && renderAttributeValue(value) !== "";
959
- }
960
- /**
961
- * Secondary identifiers for rows that have no name/title/value. Order is
962
- * chosen so the most identifying token comes first (number, then reference,
963
- * then when it happened, then how much).
964
- */
965
- function fallbackIdentityParts(attrs) {
966
- const parts = [];
967
- if (attrs.number)
968
- parts.push(`N°: ${attrs.number}`);
969
- if (attrs.reference)
970
- parts.push(`Réf: ${attrs.reference}`);
971
- if (attrs.date) {
972
- parts.push(`Date: ${attrs.date}`);
973
- }
974
- else if (attrs.startDate && attrs.endDate) {
975
- parts.push(`Du ${attrs.startDate} au ${attrs.endDate}`);
976
- }
977
- else if (attrs.startDate) {
978
- parts.push(`Début: ${attrs.startDate}`);
979
- }
980
- else if (attrs.endDate) {
981
- parts.push(`Fin: ${attrs.endDate}`);
982
- }
983
- let amounts = 0;
984
- for (const [field, label] of AMOUNT_FALLBACK_FIELDS) {
985
- if (amounts >= MAX_FALLBACK_AMOUNTS)
986
- break;
987
- const value = attrs[field];
988
- // 0 is meaningful here (an order with no turnover yet), so only
989
- // undefined/null are skipped.
990
- if (value === undefined || value === null)
991
- continue;
992
- parts.push(`${label}: ${renderAttributeValue(value)}`);
993
- amounts++;
994
- }
995
- // `typeOf` is an integer resolved through boond://dictionary/typeOf/* — on
996
- // its own it is weak, but on an action it is often the only discriminator.
997
- if (attrs.typeOf !== undefined && attrs.typeOf !== null)
998
- parts.push(`Type: ${attrs.typeOf}`);
999
- // End-user-authored free text: labelled and quoted so the model reads it as
1000
- // a data field of the row and not as server-authored instructions.
1001
- if (typeof attrs.text === "string") {
1002
- const excerpt = textExcerpt(attrs.text);
1003
- if (excerpt !== undefined)
1004
- parts.push(`Note: "${excerpt}"`);
1005
- }
1006
- return parts;
1007
- }
1008
- export function formatEntitySummary(entity) {
1009
- // A few BoondManager endpoints (e.g. `/calendars`, `/application/dictionary`)
1010
- // return reference items as flat objects without a JSON:API `attributes`
1011
- // wrapper. Treating the whole entity as the attribute bag in that case
1012
- // keeps `formatListResponse` from crashing on `attrs.firstName` and yields
1013
- // a still-useful summary.
1014
- const e = (entity ?? {});
1015
- const hasAttrs = e.attributes !== undefined && e.attributes !== null && typeof e.attributes === "object";
1016
- const attrs = hasAttrs ? e.attributes : e;
1017
- const id = e.id !== undefined ? String(e.id) : undefined;
1018
- const type = e.type !== undefined ? String(e.type) : undefined;
1019
- const header = id !== undefined && type !== undefined
1020
- ? `[${type} #${id}]`
1021
- : id !== undefined
1022
- ? `[#${id}]`
1023
- : type !== undefined
1024
- ? `[${type}]`
1025
- : "[item]";
1026
- const parts = [header];
1027
- // Common name fields
1028
- if (attrs.firstName || attrs.lastName) {
1029
- parts.push(`${attrs.firstName || ""} ${attrs.lastName || ""}`.trim());
1030
- }
1031
- if (attrs.name)
1032
- parts.push(String(attrs.name));
1033
- // `value` covers the `/calendars` and dictionary-style payloads. `0` is a
1034
- // legitimate label there, so only null/undefined/"" are skipped.
1035
- if (!attrs.firstName && !attrs.lastName && !attrs.name && hasValueIdentity(attrs.value)) {
1036
- parts.push(renderAttributeValue(attrs.value));
1037
- }
1038
- if (attrs.email1)
1039
- parts.push(`Email: ${attrs.email1}`);
1040
- if (attrs.phone1)
1041
- parts.push(`Tel: ${attrs.phone1}`);
1042
- if (attrs.city)
1043
- parts.push(`Ville: ${attrs.city}`);
1044
- if (attrs.state !== undefined)
1045
- parts.push(`Statut: ${attrs.state}`);
1046
- if (attrs.title)
1047
- parts.push(`Titre: ${attrs.title}`);
1048
- if (attrs.iso !== undefined && String(attrs.iso) !== id)
1049
- parts.push(`ISO: ${attrs.iso}`);
1050
- // Rows that named themselves are already useful — leave them untouched.
1051
- // Only the ones reduced to `[type #id]` (+ maybe a status integer) get the
1052
- // business identifiers appended.
1053
- const hasIdentity = Boolean(attrs.firstName) ||
1054
- Boolean(attrs.lastName) ||
1055
- Boolean(attrs.name) ||
1056
- Boolean(attrs.title) ||
1057
- hasValueIdentity(attrs.value);
1058
- if (!hasIdentity) {
1059
- parts.push(...fallbackIdentityParts(attrs));
1060
- }
1061
- return parts.join(" | ");
1062
- }
1063
- /**
1064
- * One result line restricted to the caller-selected attribute names.
1065
- * Unknown names are skipped silently (the schemas document this), so a typo
1066
- * degrades to a shorter line rather than an error. Non-primitive values are
1067
- * JSON-serialised — some Boond attributes are nested objects.
1068
- */
1069
- function formatProjectedSummary(entity, fields) {
1070
- const e = (entity ?? {});
1071
- const attrs = (e.attributes ?? e);
1072
- // Reference endpoints return flat rows keyed on something else than `id`
1073
- // (`/calendars` keys countries on `iso`), so a missing id renders as the same
1074
- // `[item]` token the standard summary uses — not as a `[#?]` that reads like
1075
- // a formatting bug.
1076
- const parts = [e.id !== undefined ? `[#${String(e.id)}]` : "[item]"];
1077
- for (const field of fields) {
1078
- const value = attrs[field];
1079
- if (value === undefined)
1080
- continue;
1081
- parts.push(`${field}: ${renderAttributeValue(value)}`);
1082
- }
1083
- return parts.join(" | ");
1084
- }
1085
- export function formatListResponse(response, entityType, fields) {
1086
- const data = Array.isArray(response.data) ? response.data : [response.data];
1087
- const total = response.meta?.totals?.rows;
1088
- if (data.length === 0) {
1089
- return `Aucun(e) ${entityType} trouvé(e).`;
1090
- }
1091
- const projected = fields !== undefined && fields.length > 0;
1092
- const lines = data.map((item) => (projected ? formatProjectedSummary(item, fields) : formatEntitySummary(item)));
1093
- const header = total !== undefined ? `Total: ${total} ${entityType}(s)\n\n` : "";
1094
- const body = lines.join("\n");
1095
- if (header.length + body.length <= CHARACTER_LIMIT)
1096
- return header + body;
1097
- // Cut on line boundaries and say how many rows were dropped. A mid-line cut
1098
- // produced a half-row indistinguishable from a complete one, and the count
1099
- // is what tells the model to narrow the query (or use `fields`/`pageSize`)
1100
- // instead of trusting an implicitly complete page.
1101
- const notice = (shown) => `\n\n[Résultats tronqués : ${shown}/${lines.length} ligne(s) affichée(s) (limite de ${CHARACTER_LIMIT} caractères). ` +
1102
- `Affinez les filtres, réduisez pageSize, ou utilisez 'fields' pour raccourcir chaque ligne.]`;
1103
- const budget = CHARACTER_LIMIT - header.length - notice(lines.length).length;
1104
- const kept = [];
1105
- let used = 0;
1106
- for (const line of lines) {
1107
- const cost = kept.length === 0 ? line.length : line.length + 1;
1108
- if (used + cost > budget)
1109
- break;
1110
- used += cost;
1111
- kept.push(line);
1112
- }
1113
- // A single row longer than the whole budget still has to show something.
1114
- if (kept.length === 0)
1115
- return header + body.substring(0, Math.max(budget, 0)) + notice(0);
1116
- return header + kept.join("\n") + notice(kept.length);
1117
- }
1118
- /**
1119
- * Formate la réponse d'un endpoint d'onglet (ex: /resources/{id}/positionings).
1120
- * Contrairement à formatDetailResponse, un tableau est restitué en entier :
1121
- * certains onglets renvoient plusieurs entités (positionnements, contacts...)
1122
- * et n'afficher que la première masquait les autres.
1123
- */
1124
- export function formatTabResponse(response) {
1125
- if (!Array.isArray(response.data)) {
1126
- return formatDetailResponse(response);
1127
- }
1128
- const entities = response.data.map((entity) => ({
1129
- id: entity.id,
1130
- type: entity.type,
1131
- attributes: entity.attributes,
1132
- relationships: entity.relationships,
1133
- }));
1134
- let result = `${entities.length} élément(s)\n\n` + JSON.stringify(entities, null, 2);
1135
- if (result.length > CHARACTER_LIMIT) {
1136
- result = result.substring(0, CHARACTER_LIMIT) + "\n\n[Résultat tronqué...]";
1137
- }
1138
- return result;
1139
- }
1140
- /**
1141
- * The canonical shape of a single entity as this server hands it to the model:
1142
- * the JSON:API resource minus the envelope noise (`links`, `meta`).
1143
- *
1144
- * Extracted from `formatDetailResponse` so the entity resource templates
1145
- * (`boond://candidate/{id}`) aggregate the *same* projection instead of
1146
- * defining a second, drifting idea of what an entity looks like. They cannot
1147
- * reuse `formatDetailResponse` itself: it renders one entity and truncates
1148
- * mid-string at `CHARACTER_LIMIT`, which on pretty-printed JSON yields an
1149
- * unparseable body — acceptable for a tool's text content, not for a resource
1150
- * whose whole point is to be read as JSON.
1151
- */
1152
- export function projectEntity(entity) {
1153
- return {
1154
- id: entity.id,
1155
- type: entity.type,
1156
- attributes: entity.attributes,
1157
- relationships: entity.relationships,
1158
- };
1159
- }
1160
- export function formatDetailResponse(response) {
1161
- const entity = Array.isArray(response.data) ? response.data[0] : response.data;
1162
- if (!entity)
1163
- return "Entité non trouvée.";
1164
- const result = JSON.stringify(projectEntity(entity), null, 2);
1165
- if (result.length > CHARACTER_LIMIT) {
1166
- return result.substring(0, CHARACTER_LIMIT) + "\n\n[Résultat tronqué...]";
1167
- }
1168
- return result;
1169
- }
2
+ * BoondManager client — public surface.
3
+ *
4
+ * This module is a barrel: the 38 tool files, the transports and the
5
+ * resources import from here, and the tests mock this path
6
+ * (`vi.mock("../services/boond-client.js", …)`). The implementation lives in
7
+ * one module per responsibility (issue #239):
8
+ *
9
+ * http/auth.ts who the request is sent as (JWT / static / OAuth), config
10
+ * http/errors.ts error envelope parsing, Cloudflare detection, hints, BoondApiError
11
+ * http/retry.ts retry policy: config, retryability, Retry-After, backoff
12
+ * http/rate-limit.ts per-identity token buckets
13
+ * http/transport.ts the single `send()` path + apiRequest / apiUploadForm
14
+ * http/download.ts apiDownload: streaming under a byte cap, progress, guards
15
+ * search.ts buildSearchQuery + apiSearch (per-route chunking)
16
+ * format/*.ts list / detail / tab rendering, summaries, HTML excerpts
17
+ *
18
+ * Add a new export here when a tool needs it; do not add implementation.
19
+ */
20
+ export { oauthContextAuth, buildJwt, JWT_HEADER_NAME, initClient, hasEnvCredentials, initClientWithAuth, resetClientForTests, } from "./http/auth.js";
21
+ export { parseBoondErrorBody, hintForUnauthorized, BoondApiError, formatApiError } from "./http/errors.js";
22
+ export { resolveRetryConfig, isRetryable, parseRetryAfter, computeBackoffMs } from "./http/retry.js";
23
+ export { resolveRateLimitConfig, MAX_RATE_LIMIT_BUCKETS, getRateLimiter, rateLimiterBucketCountForTests, resetRateLimiterForTests, } from "./http/rate-limit.js";
24
+ export { resolveTimeoutMs, assertSafeApiPath, resolveApiUrl, apiRequest, apiUploadForm, } from "./http/transport.js";
25
+ export { parseContentDispositionFilename, DownloadTooLargeError, apiDownload, } from "./http/download.js";
26
+ export { buildSearchQuery, apiSearch } from "./search.js";
27
+ export { formatEntitySummary } from "./format/summary.js";
28
+ export { formatListResponse } from "./format/list.js";
29
+ export { projectEntity, formatDetailResponse } from "./format/detail.js";
30
+ export { formatTabResponse } from "./format/tab.js";
1170
31
  //# sourceMappingURL=boond-client.js.map