@kammcs/buildit-mcp 0.0.0-stage → 0.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.
@@ -0,0 +1,382 @@
1
+ /**
2
+ * A small fetch wrapper for the buildIt.Social agent API.
3
+ *
4
+ * - Every call sends `Authorization: Bearer <token>`, a fresh `X-Request-Id`
5
+ * (also logged, so a call can be traced across the two systems),
6
+ * `X-Buildit-Client` (the MCP client's name and version when known, and
7
+ * this server's) and, when made on behalf of a tool, `X-Buildit-Tool`.
8
+ * - The API's error envelope `{error: {code, message, hint, details}}`
9
+ * becomes an ApiError.
10
+ * - A 429 is retried once after `retry_after`, when that wait is within the
11
+ * cap; a longer wait comes back to the agent as an error to act on. A 429
12
+ * that gives no wait (a limit in front of the API hides Retry-After) means
13
+ * a minute, as the contract says.
14
+ * - get_meta and get_me (the contract's discovery operations) carry no
15
+ * RateLimit-* headers; nothing here reads those headers.
16
+ * - Each attempt has a timeout.
17
+ *
18
+ * The client holds the token of one caller and is created per caller (once
19
+ * in stdio mode, per HTTP request in HTTP mode). It never logs the token,
20
+ * request bodies, query strings or response bodies, and it logs an
21
+ * operation's path template (/v1/items/{key}) rather than the filled-in path,
22
+ * which can carry names.
23
+ */
24
+ import { randomUUID } from 'node:crypto';
25
+ import { z } from 'zod';
26
+ import { silentLogger } from '../log.js';
27
+ import { SERVER_NAME, SERVER_VERSION } from '../version.js';
28
+ import { MeResponseSchema, MetaResponseSchema, } from './generated/schemas.js';
29
+ /**
30
+ * The API's error envelope, read tolerantly. The contract lists each code
31
+ * with its own details, but v1 may add codes, so any code is accepted here
32
+ * and the details are kept as they come.
33
+ */
34
+ const ErrorEnvelopeSchema = z.object({
35
+ error: z.looseObject({
36
+ code: z.string(),
37
+ message: z.string().optional(),
38
+ hint: z.string().optional(),
39
+ details: z.unknown().optional(),
40
+ }),
41
+ });
42
+ /** Codes produced by this client rather than by the API. */
43
+ export const LOCAL_ERROR_CODES = {
44
+ timeout: 'timeout',
45
+ network: 'network_error',
46
+ invalidResponse: 'invalid_response',
47
+ };
48
+ export class ApiError extends Error {
49
+ name = 'ApiError';
50
+ /** A stable code from the API (for example `transition_not_allowed`) or a local one. */
51
+ code;
52
+ /** The HTTP status, or 0 when no response arrived. */
53
+ status;
54
+ details;
55
+ /** The API's one-line next step for this code, when it sent one. */
56
+ hint;
57
+ requestId;
58
+ /** Seconds to wait before retrying, when the API said so. */
59
+ retryAfterSeconds;
60
+ /** Extra guidance a tool adds for its own arguments (shown after the details). */
61
+ note;
62
+ constructor(init) {
63
+ super(init.message);
64
+ this.code = init.code;
65
+ this.status = init.status;
66
+ this.details = init.details;
67
+ this.hint = init.hint;
68
+ this.requestId = init.requestId;
69
+ this.retryAfterSeconds = init.retryAfterSeconds;
70
+ this.note = init.note;
71
+ }
72
+ /** The same error with a tool's note added. */
73
+ withNote(note) {
74
+ return new ApiError({
75
+ code: this.code,
76
+ message: this.message,
77
+ status: this.status,
78
+ requestId: this.requestId,
79
+ details: this.details,
80
+ hint: this.hint,
81
+ retryAfterSeconds: this.retryAfterSeconds,
82
+ note: this.note ? [this.note, note].join('\n') : note,
83
+ });
84
+ }
85
+ }
86
+ export const DEFAULT_TIMEOUT_MS = 30_000;
87
+ export const DEFAULT_MAX_RETRY_AFTER_MS = 10_000;
88
+ /**
89
+ * The wait for a 429 that gives none: a limit in front of the API answers
90
+ * without the error envelope and, behind the gateway, without Retry-After.
91
+ * The contract says to retry after a minute then. Above the default cap, so
92
+ * it goes back to the agent instead of being waited out.
93
+ */
94
+ export const DEFAULT_RATE_LIMIT_WAIT_SECONDS = 60;
95
+ const USER_AGENT = `${SERVER_NAME}/${SERVER_VERSION}`;
96
+ const MAX_CLIENT_HEADER = 200;
97
+ /** One `name/version` product token, reduced to visible ASCII without spaces or slashes. */
98
+ function productToken(name, version) {
99
+ // "!" to "~" is the visible ASCII range; anything else (spaces too) becomes "_".
100
+ const clean = (s, max) => s
101
+ .replace(/[^!-~]+/g, '_')
102
+ .replace(/\//g, '_')
103
+ .slice(0, max);
104
+ const n = clean(name, 60) || 'unknown';
105
+ return version ? `${n}/${clean(version, 30)}` : n;
106
+ }
107
+ /**
108
+ * The X-Buildit-Client value: "client/version buildit-mcp/version", or just
109
+ * this server's token when the client didn't say who it is.
110
+ */
111
+ export function clientHeader(client) {
112
+ const server = productToken(SERVER_NAME, SERVER_VERSION);
113
+ if (!client || client.name.trim() === '')
114
+ return server;
115
+ return `${productToken(client.name, client.version)} ${server}`.slice(0, MAX_CLIENT_HEADER);
116
+ }
117
+ const defaultSleep = (ms) => new Promise((resolve) => {
118
+ setTimeout(resolve, ms);
119
+ });
120
+ function asRecord(value) {
121
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
122
+ ? value
123
+ : undefined;
124
+ }
125
+ function toSeconds(value) {
126
+ const n = typeof value === 'string' ? Number(value) : value;
127
+ return typeof n === 'number' && Number.isFinite(n) && n >= 0 ? n : undefined;
128
+ }
129
+ /** retry_after from the error details, the body, or the Retry-After header (seconds or a date). */
130
+ function readRetryAfter(body, headers) {
131
+ const root = asRecord(body);
132
+ const error = asRecord(root?.error);
133
+ const details = asRecord(error?.details);
134
+ const fromBody = toSeconds(details?.retry_after) ??
135
+ toSeconds(error?.retry_after) ??
136
+ toSeconds(root?.retry_after);
137
+ if (fromBody !== undefined)
138
+ return fromBody;
139
+ const header = headers.get('retry-after');
140
+ if (header === null)
141
+ return undefined;
142
+ const seconds = toSeconds(header.trim());
143
+ if (seconds !== undefined)
144
+ return seconds;
145
+ const date = Date.parse(header);
146
+ return Number.isNaN(date) ? undefined : Math.max(0, (date - Date.now()) / 1000);
147
+ }
148
+ export class ApiClient {
149
+ baseUrl;
150
+ token;
151
+ fetchImpl;
152
+ timeoutMs;
153
+ maxRetryAfterMs;
154
+ logger;
155
+ sleep;
156
+ onError;
157
+ /**
158
+ * What the API said of itself in the last /v1/meta this client read (stdio
159
+ * reads it at startup; whoami reads it too): its version and, from API
160
+ * 1.1.0, the operations it serves. Undefined until then. It describes the
161
+ * deployment, not the caller.
162
+ */
163
+ served;
164
+ constructor(options) {
165
+ this.baseUrl = options.baseUrl.replace(/\/+$/, '');
166
+ this.token = options.token;
167
+ this.fetchImpl = options.fetch ?? fetch;
168
+ this.timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
169
+ this.maxRetryAfterMs = options.maxRetryAfterMs ?? DEFAULT_MAX_RETRY_AFTER_MS;
170
+ this.logger = options.logger ?? silentLogger;
171
+ this.sleep = options.sleep ?? defaultSleep;
172
+ this.onError = options.onError;
173
+ }
174
+ /** GET /v1/meta */
175
+ async getMeta(options = {}) {
176
+ const meta = await this.requestParsed('GET', '/v1/meta', MetaResponseSchema, {
177
+ operation: 'get_meta',
178
+ ...options,
179
+ });
180
+ this.served = {
181
+ apiVersion: meta.api_version,
182
+ operations: meta.operations ? new Set(meta.operations) : undefined,
183
+ };
184
+ return meta;
185
+ }
186
+ /** GET /v1/me */
187
+ async getMe(options = {}) {
188
+ return this.requestParsed('GET', '/v1/me', MeResponseSchema, {
189
+ operation: 'get_me',
190
+ ...options,
191
+ });
192
+ }
193
+ /** A request whose JSON response is validated with `schema`. */
194
+ async requestParsed(method, path, schema, options = {}) {
195
+ const { body, requestId } = await this.send(method, path, options);
196
+ const parsed = schema.safeParse(body);
197
+ if (!parsed.success) {
198
+ throw new ApiError({
199
+ code: LOCAL_ERROR_CODES.invalidResponse,
200
+ message: `The API's response to ${method} ${path} did not have the expected shape. The server and the API may be out of step; check for a newer buildit-mcp.`,
201
+ status: 200,
202
+ requestId,
203
+ details: { issues: parsed.error.issues.slice(0, 5).map((i) => i.path.join('.')) },
204
+ });
205
+ }
206
+ return parsed.data;
207
+ }
208
+ /** A request whose JSON response (or undefined for 204) is returned as is. */
209
+ async request(method, path, options = {}) {
210
+ return (await this.send(method, path, options)).body;
211
+ }
212
+ buildUrl(path, query) {
213
+ const url = new URL(this.baseUrl + (path.startsWith('/') ? path : `/${path}`));
214
+ for (const [key, value] of Object.entries(query ?? {})) {
215
+ if (value === undefined)
216
+ continue;
217
+ if (Array.isArray(value)) {
218
+ for (const v of value)
219
+ url.searchParams.append(key, v);
220
+ }
221
+ else {
222
+ url.searchParams.set(key, String(value));
223
+ }
224
+ }
225
+ return url.toString();
226
+ }
227
+ async send(method, path, options) {
228
+ try {
229
+ return await this.sendOnce(method, path, options);
230
+ }
231
+ catch (err) {
232
+ if (err instanceof ApiError) {
233
+ try {
234
+ this.onError?.(err);
235
+ }
236
+ catch {
237
+ // A listener's failure must not hide the API's error.
238
+ }
239
+ }
240
+ throw err;
241
+ }
242
+ }
243
+ async sendOnce(method, path, options) {
244
+ const url = this.buildUrl(path, options.query);
245
+ let retried = false;
246
+ for (;;) {
247
+ const requestId = randomUUID();
248
+ const started = Date.now();
249
+ const logFields = {
250
+ request_id: requestId,
251
+ method,
252
+ ...(options.operation ? { operation: options.operation } : {}),
253
+ path: options.pathTemplate ?? path,
254
+ ...(options.tool ? { tool: options.tool } : {}),
255
+ };
256
+ const headers = {
257
+ Authorization: `Bearer ${this.token}`,
258
+ Accept: 'application/json',
259
+ 'User-Agent': USER_AGENT,
260
+ 'X-Request-Id': requestId,
261
+ 'X-Buildit-Client': clientHeader(options.client),
262
+ };
263
+ if (options.tool)
264
+ headers['X-Buildit-Tool'] = options.tool;
265
+ let payload;
266
+ if (options.body !== undefined) {
267
+ headers['Content-Type'] = 'application/json';
268
+ payload = JSON.stringify(options.body);
269
+ }
270
+ const timeout = AbortSignal.timeout(this.timeoutMs);
271
+ const signal = options.signal ? AbortSignal.any([options.signal, timeout]) : timeout;
272
+ let response;
273
+ try {
274
+ response = await this.fetchImpl(url, {
275
+ method,
276
+ headers,
277
+ ...(payload === undefined ? {} : { body: payload }),
278
+ signal,
279
+ redirect: 'error',
280
+ });
281
+ }
282
+ catch (err) {
283
+ const timedOut = timeout.aborted;
284
+ const aborted = options.signal?.aborted === true;
285
+ this.logger.warn('api call failed', {
286
+ ...logFields,
287
+ error: timedOut ? 'timeout' : aborted ? 'aborted' : 'network_error',
288
+ duration_ms: Date.now() - started,
289
+ });
290
+ if (aborted && !timedOut)
291
+ throw err;
292
+ throw new ApiError({
293
+ code: timedOut ? LOCAL_ERROR_CODES.timeout : LOCAL_ERROR_CODES.network,
294
+ message: timedOut
295
+ ? `The buildIt.Social API did not answer within ${Math.round(this.timeoutMs / 1000)} s. Try again shortly.`
296
+ : 'Could not reach the buildIt.Social API. Check the network and BUILDIT_API_URL, then try again.',
297
+ status: 0,
298
+ requestId,
299
+ });
300
+ }
301
+ const text = await response.text().catch(() => '');
302
+ let body = undefined;
303
+ let jsonOk = true;
304
+ if (text.length > 0) {
305
+ try {
306
+ body = JSON.parse(text);
307
+ }
308
+ catch {
309
+ jsonOk = false;
310
+ }
311
+ }
312
+ this.logger.info('api call', {
313
+ ...logFields,
314
+ status: response.status,
315
+ duration_ms: Date.now() - started,
316
+ });
317
+ if (response.status === 429 && !retried) {
318
+ const retryAfter = readRetryAfter(body, response.headers) ?? DEFAULT_RATE_LIMIT_WAIT_SECONDS;
319
+ const waitMs = Math.ceil(retryAfter * 1000);
320
+ if (waitMs <= this.maxRetryAfterMs) {
321
+ retried = true;
322
+ this.logger.info('rate limited; retrying once', { ...logFields, wait_ms: waitMs });
323
+ await this.sleep(waitMs);
324
+ continue;
325
+ }
326
+ }
327
+ if (response.ok) {
328
+ if (!jsonOk) {
329
+ throw new ApiError({
330
+ code: LOCAL_ERROR_CODES.invalidResponse,
331
+ message: `The API's response to ${method} ${path} was not JSON.`,
332
+ status: response.status,
333
+ requestId,
334
+ });
335
+ }
336
+ return { body, requestId };
337
+ }
338
+ throw this.toApiError(response, body, requestId);
339
+ }
340
+ }
341
+ toApiError(response, body, requestId) {
342
+ const retryAfterSeconds = response.status === 429
343
+ ? (readRetryAfter(body, response.headers) ?? DEFAULT_RATE_LIMIT_WAIT_SECONDS)
344
+ : undefined;
345
+ const envelope = ErrorEnvelopeSchema.safeParse(body);
346
+ if (envelope.success) {
347
+ const { code, message, hint, details } = envelope.data.error;
348
+ return new ApiError({
349
+ code,
350
+ message: message ?? `The API refused the request (${code}).`,
351
+ status: response.status,
352
+ requestId,
353
+ details,
354
+ hint,
355
+ retryAfterSeconds,
356
+ });
357
+ }
358
+ if (response.status === 429) {
359
+ // A limit in front of the API (per address) answers without the error
360
+ // envelope: it is still a rate limit, with the header's wait, else a minute.
361
+ const root = asRecord(body);
362
+ const said = typeof root?.message === 'string' ? root.message.trim().slice(0, 300) : '';
363
+ return new ApiError({
364
+ code: 'rate_limited',
365
+ message: said
366
+ ? `Too many requests: ${said}`
367
+ : 'Too many requests to the buildIt.Social API.',
368
+ status: 429,
369
+ requestId,
370
+ details: {},
371
+ retryAfterSeconds,
372
+ });
373
+ }
374
+ return new ApiError({
375
+ code: `http_${response.status}`,
376
+ message: `The API answered HTTP ${response.status}${response.statusText ? ` ${response.statusText}` : ''}.`,
377
+ status: response.status,
378
+ requestId,
379
+ retryAfterSeconds,
380
+ });
381
+ }
382
+ }