@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,43 @@
1
+ import { ToolInputError } from '../errors.js';
2
+ import { OPERATIONS } from './generated/operations.js';
3
+ /** A path segment that would climb or stay in place once the URL is resolved. */
4
+ const DOT_SEGMENT = /^\.{1,2}$/;
5
+ /**
6
+ * Fills in the path's placeholders. A value is URL-encoded, so it stays one
7
+ * segment; a value that is "." or "..", or has such a segment between
8
+ * slashes, is refused (ToolInputError) because the URL would resolve it
9
+ * into another route.
10
+ */
11
+ export function operationPath(id, values = {}) {
12
+ return OPERATIONS[id].path.replace(/\{(\w+)\}/g, (_match, name) => {
13
+ const value = values[name];
14
+ if (value === undefined)
15
+ throw new Error(`${id}: missing path parameter ${name}`);
16
+ if (value.split(/[/\\]/).some((segment) => DOT_SEGMENT.test(segment))) {
17
+ throw new ToolInputError(`${name} can't be "." or "..", or contain such a part between slashes. Use the name or id as listed.`);
18
+ }
19
+ return encodeURIComponent(value);
20
+ });
21
+ }
22
+ /** Calls one operation and parses its response with the generated (tolerant) schema. */
23
+ export async function callOperation(api, id, args, options) {
24
+ const op = OPERATIONS[id];
25
+ // The API lists what it serves from 1.1.0; an operation it leaves out would answer 404.
26
+ const served = api.served;
27
+ if (served?.operations !== undefined && !served.operations.has(id)) {
28
+ throw new ToolInputError(`This buildIt.Social server (API ${served.apiVersion}) doesn't serve ${id} yet, which this needs. Tell the person: it works once their buildIt.Social server is updated; until then they can do it in the app.`);
29
+ }
30
+ const path = operationPath(id, args.path);
31
+ const query = args.query;
32
+ const body = args.body;
33
+ const schema = op.response;
34
+ return api.requestParsed(op.method, path, schema, {
35
+ ...options,
36
+ // Logged instead of the filled-in path, which can carry names.
37
+ operation: id,
38
+ pathTemplate: op.path,
39
+ ...(query === undefined ? {} : { query }),
40
+ // A POST without a body still sends an empty object, which the contract expects.
41
+ ...(body === undefined ? (op.hasBody ? { body: {} } : {}) : { body }),
42
+ });
43
+ }
package/dist/config.js ADDED
@@ -0,0 +1,280 @@
1
+ /**
2
+ * Configuration from environment variables and command-line flags.
3
+ *
4
+ * Flags win over environment variables. The token is only ever read from the
5
+ * environment (a flag would be visible to other processes), and no message
6
+ * produced here ever contains it.
7
+ */
8
+ import { parseArgs } from 'node:util';
9
+ import { LOG_LEVELS, scrubString } from './log.js';
10
+ import { DEFAULT_TOOLSETS, isToolsetName, TOOLSET_NAMES, } from './toolsets/toolsets.js';
11
+ export const DEFAULT_API_URL = 'https://api.buildit.social/functions/v1/agent-api';
12
+ export const DEFAULT_HTTP_HOST = '127.0.0.1';
13
+ export const DEFAULT_HTTP_PORT = 8765;
14
+ export class ConfigError extends Error {
15
+ name = 'ConfigError';
16
+ }
17
+ export const USAGE = `Usage: buildit-mcp [options]
18
+
19
+ An MCP server for buildIt.Social. By default it serves one person over stdio,
20
+ with the personal access token in the BUILDIT_TOKEN environment variable.
21
+
22
+ Options (each also has an environment variable):
23
+ --api-url <url> BUILDIT_API_URL Agent API base URL
24
+ (default ${DEFAULT_API_URL})
25
+ --toolsets <list> BUILDIT_TOOLSETS Comma-separated toolsets, or "all"
26
+ (default ${DEFAULT_TOOLSETS.join(',')};
27
+ known: ${TOOLSET_NAMES.join(', ')})
28
+ --read-only BUILDIT_READ_ONLY=true Only list tools that change nothing
29
+ --exclude-tools <list> BUILDIT_EXCLUDE_TOOLS Comma-separated tool names to hide
30
+ --log-level <level> BUILDIT_LOG_LEVEL ${LOG_LEVELS.join(', ')} (default info)
31
+ --http BUILDIT_HTTP=true Serve Streamable HTTP instead of stdio
32
+ --host <host> BUILDIT_HOST HTTP bind address (default ${DEFAULT_HTTP_HOST})
33
+ --port <port> BUILDIT_PORT HTTP port (default ${DEFAULT_HTTP_PORT})
34
+ --allowed-hosts <list> BUILDIT_ALLOWED_HOSTS Hostnames accepted in the Host header
35
+ --allowed-origins <list> BUILDIT_ALLOWED_ORIGINS Hostnames accepted in the Origin header
36
+ --allow-any-host BUILDIT_ALLOW_ANY_HOST=true
37
+ Off loopback, serve without checking the
38
+ Host header (otherwise BUILDIT_ALLOWED_HOSTS
39
+ is required there)
40
+ -h, --help Show this help
41
+ -v, --version Show the version
42
+
43
+ The token is never accepted as a flag. In stdio mode set BUILDIT_TOKEN; in HTTP
44
+ mode every request carries its own "Authorization: Bearer <token>" header.
45
+ `;
46
+ const TRUE_VALUES = new Set(['1', 'true', 'yes', 'on']);
47
+ const FALSE_VALUES = new Set(['0', 'false', 'no', 'off', '']);
48
+ export function parseBoolean(name, raw, fallback) {
49
+ if (raw === undefined)
50
+ return fallback;
51
+ const value = raw.trim().toLowerCase();
52
+ if (TRUE_VALUES.has(value))
53
+ return true;
54
+ if (FALSE_VALUES.has(value))
55
+ return false;
56
+ throw new ConfigError(`${name} must be true or false (got "${raw}").`);
57
+ }
58
+ function splitList(raw) {
59
+ return [
60
+ ...new Set(raw
61
+ .split(',')
62
+ .map((s) => s.trim())
63
+ .filter((s) => s.length > 0)),
64
+ ];
65
+ }
66
+ /** Parses a toolset list such as "items,comments" or "all". Throws ConfigError on unknown names. */
67
+ export function parseToolsets(raw, source = 'BUILDIT_TOOLSETS') {
68
+ const names = splitList(raw.toLowerCase());
69
+ if (names.length === 0)
70
+ return [...DEFAULT_TOOLSETS];
71
+ if (names.includes('all'))
72
+ return [...TOOLSET_NAMES];
73
+ const unknown = names.filter((n) => !isToolsetName(n));
74
+ if (unknown.length > 0) {
75
+ throw new ConfigError(`${source} has unknown toolset(s): ${unknown.join(', ')}. Known toolsets: ${TOOLSET_NAMES.join(', ')}, or "all".`);
76
+ }
77
+ // Keep the canonical order so the tool list doesn't depend on how the list was written.
78
+ return TOOLSET_NAMES.filter((n) => names.includes(n));
79
+ }
80
+ const TOOL_NAME = /^[a-z][a-z0-9_]{0,63}$/;
81
+ function parseToolNames(raw, source) {
82
+ const names = splitList(raw.toLowerCase());
83
+ const bad = names.filter((n) => !TOOL_NAME.test(n));
84
+ if (bad.length > 0) {
85
+ throw new ConfigError(`${source} has invalid tool name(s): ${bad.join(', ')}. Tool names are lowercase letters, digits and underscores.`);
86
+ }
87
+ return names;
88
+ }
89
+ const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '::1', '[::1]']);
90
+ export function isLoopbackHost(host) {
91
+ return LOOPBACK_HOSTS.has(host.toLowerCase());
92
+ }
93
+ export function parseApiUrl(raw) {
94
+ let url;
95
+ try {
96
+ url = new URL(raw.trim());
97
+ }
98
+ catch {
99
+ throw new ConfigError(`BUILDIT_API_URL is not a valid URL (got "${raw}").`);
100
+ }
101
+ if (url.username || url.password) {
102
+ throw new ConfigError('BUILDIT_API_URL must not contain credentials.');
103
+ }
104
+ if (url.search || url.hash) {
105
+ throw new ConfigError('BUILDIT_API_URL must not have a query string or fragment.');
106
+ }
107
+ const loopback = isLoopbackHost(url.hostname);
108
+ if (url.protocol !== 'https:' && !(url.protocol === 'http:' && loopback)) {
109
+ throw new ConfigError('BUILDIT_API_URL must use https:// (plain http:// is only allowed for localhost).');
110
+ }
111
+ return url.toString().replace(/\/+$/, '');
112
+ }
113
+ const HOSTNAME = /^(\[[0-9a-f:.]+\]|[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*)$/;
114
+ const EXTENSION_ORIGIN = /^[a-z][a-z0-9+.-]*:\/\/\*$/;
115
+ /** Normalises a host or origin entry to the bare hostname the SDK's validators compare. */
116
+ function normaliseHostEntry(entry, source, allowSchemeWildcard) {
117
+ const value = entry.toLowerCase();
118
+ if (allowSchemeWildcard && EXTENSION_ORIGIN.test(value)) {
119
+ if (value === 'http://*' || value === 'https://*') {
120
+ throw new ConfigError(`${source}: "${entry}" would allow every web page; list hostnames.`);
121
+ }
122
+ return value;
123
+ }
124
+ let host;
125
+ if (value.includes('://')) {
126
+ try {
127
+ host = new URL(value).hostname;
128
+ }
129
+ catch {
130
+ throw new ConfigError(`${source}: "${entry}" is not a valid origin.`);
131
+ }
132
+ }
133
+ else if (!value.startsWith('[')) {
134
+ host = value.replace(/:\d+$/, '');
135
+ }
136
+ else {
137
+ host = value.replace(/\]:\d+$/, ']');
138
+ }
139
+ if (host === '::1')
140
+ host = '[::1]';
141
+ if (!HOSTNAME.test(host)) {
142
+ throw new ConfigError(`${source}: "${entry}" is not a valid hostname.`);
143
+ }
144
+ return host;
145
+ }
146
+ function parseHostList(raw, source, allowSchemeWildcard) {
147
+ return [
148
+ ...new Set(splitList(raw).map((e) => normaliseHostEntry(e, source, allowSchemeWildcard))),
149
+ ];
150
+ }
151
+ function parsePort(raw) {
152
+ const value = raw.trim();
153
+ if (!/^\d+$/.test(value))
154
+ throw new ConfigError(`The port must be a number (got "${raw}").`);
155
+ const port = Number(value);
156
+ if (port > 65535)
157
+ throw new ConfigError(`The port must be at most 65535 (got ${port}).`);
158
+ return port;
159
+ }
160
+ function parseLogLevel(raw) {
161
+ const value = raw.trim().toLowerCase();
162
+ if (LOG_LEVELS.includes(value))
163
+ return value;
164
+ throw new ConfigError(`The log level must be one of ${LOG_LEVELS.join(', ')} (got "${raw}").`);
165
+ }
166
+ function nonEmpty(value) {
167
+ return value === undefined || value.trim() === '' ? undefined : value;
168
+ }
169
+ /**
170
+ * Parses flags and environment variables. Throws ConfigError with a message
171
+ * that is safe to print (it never contains the token).
172
+ */
173
+ export function parseCli(argv, env) {
174
+ const secrets = env.BUILDIT_TOKEN ? [env.BUILDIT_TOKEN] : [];
175
+ if (argv.some((a) => a === '--token' || a.startsWith('--token='))) {
176
+ throw new ConfigError('The token is never accepted as a flag (other processes can read flags). Set the BUILDIT_TOKEN environment variable instead.');
177
+ }
178
+ let values;
179
+ try {
180
+ ({ values } = parseArgs({
181
+ args: [...argv],
182
+ strict: true,
183
+ allowPositionals: false,
184
+ options: {
185
+ 'api-url': { type: 'string' },
186
+ toolsets: { type: 'string' },
187
+ 'read-only': { type: 'boolean' },
188
+ 'exclude-tools': { type: 'string' },
189
+ 'log-level': { type: 'string' },
190
+ http: { type: 'boolean' },
191
+ host: { type: 'string' },
192
+ port: { type: 'string' },
193
+ 'allowed-hosts': { type: 'string' },
194
+ 'allowed-origins': { type: 'string' },
195
+ 'allow-any-host': { type: 'boolean' },
196
+ help: { type: 'boolean', short: 'h' },
197
+ version: { type: 'boolean', short: 'v' },
198
+ },
199
+ }));
200
+ }
201
+ catch (err) {
202
+ const message = err instanceof Error ? err.message : String(err);
203
+ throw new ConfigError(`${scrubString(message, secrets)} Run with --help for the options.`);
204
+ }
205
+ if (values.help === true)
206
+ return { kind: 'help' };
207
+ if (values.version === true)
208
+ return { kind: 'version' };
209
+ const str = (flag, envName) => {
210
+ const fromFlag = values[flag];
211
+ return typeof fromFlag === 'string' ? fromFlag : nonEmpty(env[envName]);
212
+ };
213
+ const warnings = [];
214
+ const http = values.http === true || parseBoolean('BUILDIT_HTTP', env.BUILDIT_HTTP, false);
215
+ const mode = http ? 'http' : 'stdio';
216
+ const apiUrl = parseApiUrl(str('api-url', 'BUILDIT_API_URL') ?? DEFAULT_API_URL);
217
+ const toolsetsRaw = str('toolsets', 'BUILDIT_TOOLSETS');
218
+ const toolsets = toolsetsRaw === undefined ? [...DEFAULT_TOOLSETS] : parseToolsets(toolsetsRaw);
219
+ const readOnly = values['read-only'] === true || parseBoolean('BUILDIT_READ_ONLY', env.BUILDIT_READ_ONLY, false);
220
+ const excludeRaw = str('exclude-tools', 'BUILDIT_EXCLUDE_TOOLS');
221
+ const excludeTools = excludeRaw === undefined ? [] : parseToolNames(excludeRaw, 'BUILDIT_EXCLUDE_TOOLS');
222
+ const logLevelRaw = str('log-level', 'BUILDIT_LOG_LEVEL');
223
+ const logLevel = logLevelRaw === undefined ? 'info' : parseLogLevel(logLevelRaw);
224
+ const host = (str('host', 'BUILDIT_HOST') ?? DEFAULT_HTTP_HOST).trim();
225
+ if (host === '')
226
+ throw new ConfigError('The HTTP host must not be empty.');
227
+ const portRaw = str('port', 'BUILDIT_PORT');
228
+ const port = portRaw === undefined ? DEFAULT_HTTP_PORT : parsePort(portRaw);
229
+ const hostsRaw = str('allowed-hosts', 'BUILDIT_ALLOWED_HOSTS');
230
+ const originsRaw = str('allowed-origins', 'BUILDIT_ALLOWED_ORIGINS');
231
+ const httpConfig = { host, port };
232
+ if (hostsRaw !== undefined) {
233
+ httpConfig.allowedHosts = parseHostList(hostsRaw, 'BUILDIT_ALLOWED_HOSTS', false);
234
+ }
235
+ if (originsRaw !== undefined) {
236
+ httpConfig.allowedOrigins = parseHostList(originsRaw, 'BUILDIT_ALLOWED_ORIGINS', true);
237
+ }
238
+ let token;
239
+ const rawToken = env.BUILDIT_TOKEN;
240
+ if (mode === 'stdio') {
241
+ if (rawToken === undefined || rawToken.trim() === '') {
242
+ throw new ConfigError('BUILDIT_TOKEN is not set. Create a personal access token in buildIt.Social and put it in the BUILDIT_TOKEN environment variable (never in a file you commit).');
243
+ }
244
+ token = rawToken.trim();
245
+ if (/\s/.test(token)) {
246
+ throw new ConfigError('BUILDIT_TOKEN contains whitespace; check that it was copied whole.');
247
+ }
248
+ if (!token.startsWith('buildit_pat_')) {
249
+ warnings.push('BUILDIT_TOKEN does not start with "buildit_pat_"; buildIt.Social personal access tokens do.');
250
+ }
251
+ }
252
+ else {
253
+ if (rawToken !== undefined && rawToken.trim() !== '') {
254
+ warnings.push('BUILDIT_TOKEN is ignored in HTTP mode: each request must carry its own Authorization header.');
255
+ }
256
+ if (!isLoopbackHost(host) && httpConfig.allowedHosts === undefined) {
257
+ const anyHost = values['allow-any-host'] === true ||
258
+ parseBoolean('BUILDIT_ALLOW_ANY_HOST', env.BUILDIT_ALLOW_ANY_HOST, false);
259
+ if (!anyHost) {
260
+ throw new ConfigError(`Binding to ${host} needs BUILDIT_ALLOWED_HOSTS (or --allowed-hosts): the hostnames clients use, so the Host header can be checked. To serve without that check (for example behind a proxy that checks it), set BUILDIT_ALLOW_ANY_HOST=true or pass --allow-any-host.`);
261
+ }
262
+ httpConfig.allowAnyHost = true;
263
+ warnings.push(`Binding to ${host} without BUILDIT_ALLOWED_HOSTS, as BUILDIT_ALLOW_ANY_HOST asks: the Host header is not checked.`);
264
+ }
265
+ }
266
+ return {
267
+ kind: 'run',
268
+ config: {
269
+ mode,
270
+ apiUrl,
271
+ ...(token === undefined ? {} : { token }),
272
+ toolsets,
273
+ readOnly,
274
+ excludeTools,
275
+ http: httpConfig,
276
+ logLevel,
277
+ warnings,
278
+ },
279
+ };
280
+ }
package/dist/errors.js ADDED
@@ -0,0 +1,347 @@
1
+ import { ApiError, LOCAL_ERROR_CODES } from './api/client.js';
2
+ import { ERROR_HINTS } from './api/generated/operations.js';
3
+ import { scrubString } from './log.js';
4
+ import { neutralize, sanitizeLabel, wrapUntrusted } from './untrusted.js';
5
+ const MAX_DETAILS_CHARS = 4000;
6
+ /**
7
+ * Arguments that pass the input schema but can't make a valid call (for
8
+ * example two options that exclude each other). The text says how to fix
9
+ * the call; nothing was sent to the API.
10
+ */
11
+ export class ToolInputError extends Error {
12
+ name = 'ToolInputError';
13
+ code = 'invalid_arguments';
14
+ }
15
+ /**
16
+ * What to do, for the codes this server makes itself. The API's own codes
17
+ * come with a hint (the error envelope's `hint`); for an older API that
18
+ * sends none, the contract's wording (ERROR_HINTS, generated) is used.
19
+ */
20
+ const LOCAL_HINTS = {
21
+ http_401: 'The token was refused. Ask the person to check it in buildIt.Social, under Settings → Agent tokens (it may be expired, revoked, or suspended by the org).',
22
+ http_403: 'The token or its owner may not do this. Do not retry the same call.',
23
+ http_429: 'Too many calls. Wait a little, then retry.',
24
+ invalid_arguments: 'Fix the arguments as described, then call the tool again.',
25
+ [LOCAL_ERROR_CODES.timeout]: 'The API was slow to answer. Retry once; if it fails again, stop.',
26
+ [LOCAL_ERROR_CODES.network]: 'The API could not be reached. Retry later.',
27
+ [LOCAL_ERROR_CODES.invalidResponse]: 'This server may be older than the API. Ask the person to update buildit-mcp.',
28
+ };
29
+ /**
30
+ * Notes that put the API's advice in this server's terms (its tools and
31
+ * settings), shown after the hint, only where they add something: none when
32
+ * the hint or the details already say it.
33
+ */
34
+ const NEW_PLAN_NOTE = "In this server, that means calling the same propose_* tool again. Then show the person the new preview and wait for their yes before apply_plan: their yes to the old plan doesn't carry over.";
35
+ const TOOL_NOTES = {
36
+ token_invalid: () => 'In this server the token comes from BUILDIT_TOKEN (or the Authorization header in HTTP mode).',
37
+ // The details list the token's scopes; without them, whoami does.
38
+ scope_missing: (d) => Array.isArray(d.granted) ? undefined : 'whoami lists the scopes this token has.',
39
+ validation: () => 'describe_project lists the allowed statuses, types, labels, fields and estimates.',
40
+ conflict: (d) => d.kind === 'page'
41
+ ? 'get_page reads the current text and version.'
42
+ : d.kind === 'item' || d.kind === 'description'
43
+ ? 'get_item reads the current item, version and description_version.'
44
+ : undefined,
45
+ // An eval agent re-proposed after plan_expired and applied the new plan
46
+ // without asking: a yes is for the plan the person saw.
47
+ plan_stale: () => NEW_PLAN_NOTE,
48
+ plan_expired: () => NEW_PLAN_NOTE,
49
+ };
50
+ /**
51
+ * What to do about a not_found, by the kind of thing that wasn't found, in
52
+ * this server's tools. The API's own hint names the item and project tools
53
+ * whatever the kind.
54
+ */
55
+ const NOT_FOUND_HINTS = {
56
+ project: 'whoami lists the project keys in reach (list_projects too); check the key, then retry.',
57
+ item: 'Check the key with search_items (the item may have moved or been deleted), then retry.',
58
+ initiative: 'Check the key with search_items, then retry.',
59
+ status: "describe_project lists the project's statuses by workflow; use one of those names.",
60
+ type: "describe_project lists the project's types; use one of those names.",
61
+ label: "describe_project lists the project's labels; use one of those names.",
62
+ field: "describe_project lists the project's fields; use one of those names.",
63
+ option: "describe_project lists each field's options; use one of those.",
64
+ workflow: "describe_project lists the project's workflows; use one of those names.",
65
+ user: 'find_users lists the people of the project; use an email or "me".',
66
+ sprint: 'list_sprints lists the project\'s sprints; name one by number, name or "active".',
67
+ release: "list_releases lists the project's releases; use one of those names.",
68
+ link: "get_item lists the item's links; unlink only one that is listed.",
69
+ comment: "list_comments lists the item's comments and their ids.",
70
+ page: "list_pages lists a channel's pages and their ids.",
71
+ channel: 'list_channels lists the channels in reach; use one of those names.',
72
+ message: 'read_channel lists the messages of a channel and their ids.',
73
+ plan: 'Use a handle a propose_* tool returned in this conversation, or propose the change again.',
74
+ route: 'The API does not serve this call: it may be older than this buildit-mcp. Check BUILDIT_API_URL, or use another tool.',
75
+ };
76
+ /**
77
+ * Each of the contract's rate-limit buckets, in plain words. A bucket the
78
+ * contract adds later is shown by retry_after alone, as the contract asks.
79
+ */
80
+ const RATE_BUCKETS = {
81
+ token_requests_per_minute: 'requests per minute for this token',
82
+ token_writes_per_minute: 'writes per minute for this token',
83
+ token_writes_per_day: 'writes per day for this token',
84
+ org_requests_per_minute: 'requests per minute for the whole org, across all its tokens',
85
+ user_requests_per_minute: 'requests per minute for your account, across all your tokens',
86
+ };
87
+ /** The release operations, as plan_release's actions. */
88
+ const RELEASE_ACTIONS = {
89
+ create_release: 'create',
90
+ release_version: 'release',
91
+ update_release: 'update',
92
+ reopen_release: 'reopen',
93
+ archive_release: 'archive',
94
+ restore_release: 'restore',
95
+ };
96
+ /** The API's own terms, as this server names them. */
97
+ function inServerTerms(hint) {
98
+ return hint
99
+ .replace(/\bget_me\b/g, 'whoami')
100
+ .replace(/\b(create_release|release_version|update_release|reopen_release|archive_release|restore_release)\b/g, (op) => `plan_release (action "${RELEASE_ACTIONS[op] ?? op}")`);
101
+ }
102
+ /** The next step for an error: the API's hint, else the contract's, else this server's own. */
103
+ export function hintFor(code, apiHint) {
104
+ return apiHint ?? ERROR_HINTS[code] ?? LOCAL_HINTS[code];
105
+ }
106
+ /** Lower case letters and digits only, to tell whether one text already says another. */
107
+ function squash(text) {
108
+ return text.toLowerCase().replace(/[^a-z0-9]+/g, '');
109
+ }
110
+ /** Whether a sentence points at the wait (details.retry_after, the Retry-After header). */
111
+ const RETRY_ADVICE = /retry[_-]after/i;
112
+ /**
113
+ * A rate-limit message without its pointer to the wait, which the
114
+ * "Retry after" line gives in seconds: "Too many requests. Retry after the
115
+ * number of seconds in details.retry_after ..." becomes "Too many requests.".
116
+ */
117
+ function withoutRetryAdvice(message) {
118
+ const kept = message
119
+ .split(/(?<=[.!?])\s+/)
120
+ .filter((sentence) => !RETRY_ADVICE.test(sentence))
121
+ .join(' ')
122
+ .trim();
123
+ return kept === '' ? 'Too many requests.' : kept;
124
+ }
125
+ /** Whether `text` adds nothing to what `shown` already says. */
126
+ function alreadySaid(text, shown) {
127
+ const t = squash(text);
128
+ return t === '' || shown.some((s) => squash(s).includes(t));
129
+ }
130
+ function record(value) {
131
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
132
+ ? value
133
+ : undefined;
134
+ }
135
+ function str(value) {
136
+ if (value === undefined)
137
+ return 'unknown';
138
+ return typeof value === 'string' ? sanitizeLabel(value, 300) : JSON.stringify(value);
139
+ }
140
+ function list(value) {
141
+ return Array.isArray(value) ? value.map(str).join(', ') || 'none' : str(value);
142
+ }
143
+ function formatValue(value) {
144
+ if (Array.isArray(value) && value.every((v) => typeof v === 'string' || typeof v === 'number')) {
145
+ return value.join(', ');
146
+ }
147
+ if (typeof value === 'string')
148
+ return value;
149
+ return JSON.stringify(value);
150
+ }
151
+ /** Code-specific renderings of the details, so the agent reads what matters. */
152
+ function formatKnownDetails(code, d) {
153
+ switch (code) {
154
+ case 'ambiguous': {
155
+ const candidates = Array.isArray(d.candidates) ? d.candidates : [];
156
+ return [
157
+ `- ${str(d.kind)} "${str(d.ref)}" matches:`,
158
+ ...candidates.map((c) => {
159
+ const r = record(c);
160
+ return ` - ${str(r?.label)} (id ${str(r?.id)})`;
161
+ }),
162
+ ];
163
+ }
164
+ case 'conflict': {
165
+ const current = record(d.current) ?? {};
166
+ const parts = Object.entries(current).map(([k, v]) => `${k} ${str(v)}`);
167
+ return [`- ${str(d.kind)} changed; current: ${parts.join(', ') || 'unknown'}`];
168
+ }
169
+ case 'transition_not_allowed': {
170
+ const moves = Array.isArray(d.moves) ? d.moves.map(record) : undefined;
171
+ return [
172
+ `- from: ${str(d.from)}`,
173
+ `- asked for: ${str(d.to)}`,
174
+ ...(moves
175
+ ? [
176
+ `- moves allowed from here: ${moves
177
+ .map((m) => {
178
+ const needs = Array.isArray(m?.required_fields) ? m.required_fields : [];
179
+ const notes = [
180
+ ...(needs.length > 0 ? [`needs ${list(needs)}`] : []),
181
+ ...(m?.admins_only === true ? ['project admins only'] : []),
182
+ ];
183
+ return notes.length > 0 ? `${str(m?.to)} (${notes.join('; ')})` : str(m?.to);
184
+ })
185
+ .join('; ') || 'none'}`,
186
+ ]
187
+ : [`- allowed from here: ${list(d.allowed)}`]),
188
+ ...(Array.isArray(d.required_fields) && d.required_fields.length > 0
189
+ ? [`- the asked-for move needs: ${list(d.required_fields)}`]
190
+ : []),
191
+ ...(d.admins_only === true ? ['- the asked-for move is for project admins only'] : []),
192
+ ];
193
+ }
194
+ case 'field_required':
195
+ return [`- set: ${list(d.fields)}`];
196
+ case 'validation':
197
+ case 'image_layout_invalid': {
198
+ const fields = Array.isArray(d.fields) ? d.fields : [];
199
+ return fields.map((f) => {
200
+ const r = record(f);
201
+ return `- ${str(r?.path)}: ${str(r?.message)}`;
202
+ });
203
+ }
204
+ case 'scope_missing':
205
+ return [`- needs: ${str(d.scope)}`, `- the token has: ${list(d.granted)}`];
206
+ case 'not_found':
207
+ case 'archived':
208
+ case 'outside_limits':
209
+ return [`- ${str(d.kind)}: ${str(d.ref)}`];
210
+ case 'plan_used':
211
+ return [`- plan ${str(d.handle)} was applied at ${str(d.used_at)}`];
212
+ case 'plan_expired':
213
+ return [`- plan ${str(d.handle)}`];
214
+ case 'plan_stale': {
215
+ const changed = Array.isArray(d.changed) ? d.changed : [];
216
+ return [
217
+ `- plan ${str(d.handle)}; changed since the preview: ${changed
218
+ .map((c) => {
219
+ const r = record(c);
220
+ return `${str(r?.kind)} ${str(r?.ref)}`;
221
+ })
222
+ .join(', ') || 'unknown'}`,
223
+ ];
224
+ }
225
+ case 'bulk_too_large':
226
+ return [`- ${str(d.count)} items; at most ${str(d.max)}`];
227
+ case 'limit_reached':
228
+ return [`- ${str(d.limit)}: at most ${str(d.max)}`];
229
+ case 'rate_limited':
230
+ // retry_after and bucket are shown above, in words.
231
+ return [];
232
+ default:
233
+ return undefined;
234
+ }
235
+ }
236
+ /**
237
+ * Renders the details object as lines, scrubbed of anything token-shaped,
238
+ * inside one untrusted block: details can echo people-written names
239
+ * (statuses, the candidates of an ambiguous name, refs).
240
+ */
241
+ function formatDetails(code, details) {
242
+ if (details === undefined || details === null)
243
+ return undefined;
244
+ const obj = record(details);
245
+ let text;
246
+ if (obj) {
247
+ if (Object.keys(obj).length === 0)
248
+ return undefined;
249
+ const known = formatKnownDetails(code, obj);
250
+ if (known?.length === 0)
251
+ return undefined;
252
+ text = (known ?? Object.entries(obj).map(([key, value]) => `- ${key}: ${formatValue(value)}`)).join('\n');
253
+ }
254
+ else {
255
+ text = formatValue(details);
256
+ }
257
+ // eslint-disable-next-line no-control-regex -- stripping control characters is the point
258
+ text = scrubString(text.replace(/[\u0000-\u0008\u000B-\u001F\u007F]/g, ' '));
259
+ return wrapUntrusted(text, {
260
+ source: 'error_details',
261
+ maxChars: MAX_DETAILS_CHARS,
262
+ moreHint: 'The rest of the details is left out.',
263
+ });
264
+ }
265
+ /** Text from the API or a tool, made safe to show: nothing token-shaped, no block tags. */
266
+ function clean(text) {
267
+ return neutralize(scrubString(text));
268
+ }
269
+ /** The actionable text for an ApiError. */
270
+ export function describeApiError(err) {
271
+ const details = record(err.details) ?? {};
272
+ const rateLimited = err.code === 'rate_limited' || err.status === 429;
273
+ // With the wait in seconds on its own line, the message's and the hint's
274
+ // pointers to details.retry_after (which isn't shown) would repeat it.
275
+ const waitShown = rateLimited && err.retryAfterSeconds !== undefined;
276
+ const message = clean(waitShown ? withoutRetryAdvice(err.message) : err.message);
277
+ const lines = [
278
+ `buildIt.Social API error: ${err.code}${err.status ? ` (HTTP ${err.status})` : ''}`,
279
+ message,
280
+ ];
281
+ // Each piece of advice once: the message, then the hint, then a note in
282
+ // this server's terms, each left out when what came before says it.
283
+ const said = [message];
284
+ const kindHint = err.code === 'not_found' && typeof details.kind === 'string'
285
+ ? NOT_FOUND_HINTS[details.kind]
286
+ : undefined;
287
+ const hint = kindHint ?? hintFor(err.code, err.hint);
288
+ if (hint && !(waitShown && RETRY_ADVICE.test(hint))) {
289
+ const text = clean(inServerTerms(hint));
290
+ if (!alreadySaid(text, said)) {
291
+ lines.push(`What to do: ${text}`);
292
+ said.push(text);
293
+ }
294
+ }
295
+ const note = err.note ? clean(err.note) : undefined;
296
+ // A tool's own note is more precise than the general one.
297
+ const toolNote = note === undefined ? TOOL_NOTES[err.code]?.(details) : undefined;
298
+ if (toolNote && !alreadySaid(toolNote, said)) {
299
+ lines.push(toolNote);
300
+ said.push(toolNote);
301
+ }
302
+ if (err.retryAfterSeconds !== undefined) {
303
+ lines.push(`Retry after: ${Math.ceil(err.retryAfterSeconds)} s`);
304
+ }
305
+ if (rateLimited && typeof details.bucket === 'string') {
306
+ const bucket = RATE_BUCKETS[details.bucket];
307
+ if (bucket !== undefined)
308
+ lines.push(`Limit reached: ${bucket}.`);
309
+ }
310
+ const formatted = formatDetails(err.code, err.details);
311
+ if (formatted)
312
+ lines.push('Details:', formatted);
313
+ if (note && !alreadySaid(note, said))
314
+ lines.push(note);
315
+ lines.push(`Request id: ${err.requestId}`);
316
+ return lines.join('\n');
317
+ }
318
+ /** A tool result for any failure inside a tool handler. */
319
+ export function toolErrorResult(err, requestId) {
320
+ if (err instanceof ApiError) {
321
+ return { isError: true, content: [{ type: 'text', text: describeApiError(err) }] };
322
+ }
323
+ if (err instanceof ToolInputError) {
324
+ return {
325
+ isError: true,
326
+ content: [
327
+ {
328
+ type: 'text',
329
+ text: [
330
+ `buildit-mcp error: ${err.code} (nothing was sent to buildIt.Social)`,
331
+ clean(err.message),
332
+ `What to do: ${LOCAL_HINTS.invalid_arguments ?? ''}`,
333
+ ].join('\n'),
334
+ },
335
+ ],
336
+ };
337
+ }
338
+ return {
339
+ isError: true,
340
+ content: [
341
+ {
342
+ type: 'text',
343
+ text: `Internal error in buildit-mcp${requestId ? ` (request id ${requestId})` : ''}. This is a bug in the server, not in your call; retrying is unlikely to help.`,
344
+ },
345
+ ],
346
+ };
347
+ }