@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.
- package/CHANGELOG.md +84 -0
- package/LICENSE +21 -0
- package/README.md +325 -2
- package/SECURITY.md +48 -0
- package/dist/api/client.js +382 -0
- package/dist/api/generated/operations.js +1615 -0
- package/dist/api/generated/schemas.js +1413 -0
- package/dist/api/generated/strict.js +1681 -0
- package/dist/api/operations.js +43 -0
- package/dist/config.js +280 -0
- package/dist/errors.js +347 -0
- package/dist/identity.js +124 -0
- package/dist/index.js +73 -0
- package/dist/log.js +90 -0
- package/dist/prompts.js +114 -0
- package/dist/resources.js +55 -0
- package/dist/server.js +257 -0
- package/dist/tools/chat.js +273 -0
- package/dist/tools/comments.js +128 -0
- package/dist/tools/items-read.js +603 -0
- package/dist/tools/items-write.js +580 -0
- package/dist/tools/pages.js +405 -0
- package/dist/tools/planning.js +697 -0
- package/dist/tools/plans.js +906 -0
- package/dist/tools/projects.js +415 -0
- package/dist/tools/setup.js +292 -0
- package/dist/tools/shared.js +462 -0
- package/dist/tools/whoami.js +230 -0
- package/dist/toolsets/catalog.js +72 -0
- package/dist/toolsets/registry.js +97 -0
- package/dist/toolsets/toolsets.js +89 -0
- package/dist/transports/http.js +212 -0
- package/dist/transports/stdio.js +203 -0
- package/dist/untrusted.js +466 -0
- package/dist/version.js +28 -0
- package/package.json +75 -4
|
@@ -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
|
+
}
|