@kammcs/buildit-mcp 0.2.0 → 0.7.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 +116 -0
- package/README.md +252 -32
- package/SECURITY.md +1 -1
- package/dist/api/client.js +5 -1
- package/dist/api/generated/operations.js +661 -4
- package/dist/api/generated/schemas.js +680 -2
- package/dist/api/generated/strict.js +799 -2
- package/dist/channel/api.js +128 -0
- package/dist/channel/approvals.js +259 -0
- package/dist/channel/item-events.js +138 -0
- package/dist/channel/lock.js +181 -0
- package/dist/channel/loop.js +776 -0
- package/dist/channel/mask.js +365 -0
- package/dist/channel/threads.js +60 -0
- package/dist/channel/tool-ring.js +124 -0
- package/dist/channel/typing.js +150 -0
- package/dist/channel/waiters.js +229 -0
- package/dist/channel/workers.js +66 -0
- package/dist/cli/claude-config.js +100 -0
- package/dist/cli/commands.js +410 -0
- package/dist/config.js +329 -6
- package/dist/errors.js +38 -0
- package/dist/index.js +122 -6
- package/dist/log.js +3 -2
- package/dist/secrets/keychain.js +273 -0
- package/dist/server.js +53 -5
- package/dist/tools/approve.js +791 -0
- package/dist/tools/ask.js +898 -0
- package/dist/tools/chat.js +98 -1
- package/dist/tools/items-read.js +5 -3
- package/dist/tools/items-write.js +66 -15
- package/dist/tools/plans.js +221 -14
- package/dist/tools/reply.js +138 -0
- package/dist/tools/shared.js +10 -1
- package/dist/tools/threads.js +123 -0
- package/dist/tools/whoami.js +17 -3
- package/dist/toolsets/catalog.js +3 -1
- package/dist/toolsets/registry.js +2 -0
- package/dist/transports/stdio.js +260 -6
- package/package.json +5 -1
package/dist/config.js
CHANGED
|
@@ -5,25 +5,45 @@
|
|
|
5
5
|
* environment (a flag would be visible to other processes), and no message
|
|
6
6
|
* produced here ever contains it.
|
|
7
7
|
*/
|
|
8
|
+
import { basename, resolve } from 'node:path';
|
|
8
9
|
import { parseArgs } from 'node:util';
|
|
9
10
|
import { LOG_LEVELS, scrubString } from './log.js';
|
|
10
11
|
import { DEFAULT_TOOLSETS, isToolsetName, TOOLSET_NAMES, } from './toolsets/toolsets.js';
|
|
11
12
|
export const DEFAULT_API_URL = 'https://api.buildit.social/functions/v1/agent-api';
|
|
12
13
|
export const DEFAULT_HTTP_HOST = '127.0.0.1';
|
|
13
14
|
export const DEFAULT_HTTP_PORT = 8765;
|
|
15
|
+
export const DEFAULT_ASK_TIMEOUT_MINUTES = 60;
|
|
16
|
+
export const DEFAULT_APPROVAL_TIMEOUT_MINUTES = 60;
|
|
17
|
+
export const DEFAULT_MAX_PARALLEL_ITEMS = 3;
|
|
14
18
|
export class ConfigError extends Error {
|
|
15
19
|
name = 'ConfigError';
|
|
16
20
|
}
|
|
17
21
|
export const USAGE = `Usage: buildit-mcp [options]
|
|
22
|
+
buildit-mcp connect --project <KEY> [--label <name>] [--folder <path>] [--api-url <url>]
|
|
23
|
+
buildit-mcp disconnect [--folder <path>] [--api-url <url>]
|
|
24
|
+
buildit-mcp status [--folder <path>] [--api-url <url>]
|
|
18
25
|
|
|
19
26
|
An MCP server for buildIt.Social. By default it serves one person over stdio,
|
|
20
27
|
with the personal access token in the BUILDIT_TOKEN environment variable.
|
|
28
|
+
With --channel it is also a Claude Code channel: the messages its owner sends
|
|
29
|
+
to the agent in buildIt.Social arrive in the Claude Code session, which
|
|
30
|
+
answers with the reply tool. Its token is the session token that
|
|
31
|
+
"buildit-mcp connect" saved for the folder (or BUILDIT_TOKEN).
|
|
32
|
+
|
|
33
|
+
Commands (run them in a terminal, in the folder where Claude Code runs):
|
|
34
|
+
connect Connect this folder to buildIt.Social as an agent
|
|
35
|
+
session: shows a code to approve in the app, then
|
|
36
|
+
saves the session token in the OS keychain
|
|
37
|
+
disconnect Forget this folder's saved session
|
|
38
|
+
status Show this folder's session
|
|
39
|
+
Run "buildit-mcp <command> --help" for a command's options.
|
|
21
40
|
|
|
22
41
|
Options (each also has an environment variable):
|
|
23
42
|
--api-url <url> BUILDIT_API_URL Agent API base URL
|
|
24
43
|
(default ${DEFAULT_API_URL})
|
|
25
44
|
--toolsets <list> BUILDIT_TOOLSETS Comma-separated toolsets, or "all"
|
|
26
|
-
(default ${DEFAULT_TOOLSETS.join(',')}
|
|
45
|
+
(default ${DEFAULT_TOOLSETS.join(',')}, or all
|
|
46
|
+
with --channel;
|
|
27
47
|
known: ${TOOLSET_NAMES.join(', ')})
|
|
28
48
|
--read-only BUILDIT_READ_ONLY=true Only list tools that change nothing
|
|
29
49
|
--exclude-tools <list> BUILDIT_EXCLUDE_TOOLS Comma-separated tool names to hide
|
|
@@ -33,6 +53,26 @@ Options (each also has an environment variable):
|
|
|
33
53
|
--port <port> BUILDIT_PORT HTTP port (default ${DEFAULT_HTTP_PORT})
|
|
34
54
|
--allowed-hosts <list> BUILDIT_ALLOWED_HOSTS Hostnames accepted in the Host header
|
|
35
55
|
--allowed-origins <list> BUILDIT_ALLOWED_ORIGINS Hostnames accepted in the Origin header
|
|
56
|
+
--channel BUILDIT_CHANNEL=true Claude Code channel mode (stdio only)
|
|
57
|
+
--folder <path> BUILDIT_FOLDER The folder whose saved session --channel uses
|
|
58
|
+
(default: the working directory)
|
|
59
|
+
--from-plugin --channel: started by the buildit plugin, which
|
|
60
|
+
Claude Code registers as the channel (the
|
|
61
|
+
plugin passes it)
|
|
62
|
+
BUILDIT_QUESTIONS_IN_APP --channel: the plugin's question hook asks in
|
|
63
|
+
buildIt.Social (default true)
|
|
64
|
+
BUILDIT_ASK_TIMEOUT_MINUTES
|
|
65
|
+
--channel: how long a question card waits,
|
|
66
|
+
1 to 60 (default 60)
|
|
67
|
+
BUILDIT_MAX_PARALLEL_ITEMS
|
|
68
|
+
--channel with the plugin: how many work items
|
|
69
|
+
are worked on at once, 1 to 10 (default 3)
|
|
70
|
+
BUILDIT_APPROVALS_IN_APP --channel: the plugin's permission hook asks to
|
|
71
|
+
approve tool calls in buildIt.Social too
|
|
72
|
+
(default true)
|
|
73
|
+
BUILDIT_APPROVAL_TIMEOUT_MINUTES
|
|
74
|
+
--channel: how long a tool approval card waits,
|
|
75
|
+
1 to 60 (default 60)
|
|
36
76
|
--allow-any-host BUILDIT_ALLOW_ANY_HOST=true
|
|
37
77
|
Off loopback, serve without checking the
|
|
38
78
|
Host header (otherwise BUILDIT_ALLOWED_HOSTS
|
|
@@ -43,6 +83,55 @@ Options (each also has an environment variable):
|
|
|
43
83
|
The token is never accepted as a flag. In stdio mode set BUILDIT_TOKEN; in HTTP
|
|
44
84
|
mode every request carries its own "Authorization: Bearer <token>" header.
|
|
45
85
|
`;
|
|
86
|
+
export const COMMAND_USAGE = {
|
|
87
|
+
connect: `Usage: buildit-mcp connect --project <KEY> [options]
|
|
88
|
+
|
|
89
|
+
Connects this folder to buildIt.Social as a session of your personal agent,
|
|
90
|
+
for Claude Code's channel mode (buildit-mcp --channel). It shows a code and a
|
|
91
|
+
link: open the link, check the code, and approve in buildIt.Social. The
|
|
92
|
+
session token is then saved in the OS keychain for this folder and API.
|
|
93
|
+
|
|
94
|
+
Options:
|
|
95
|
+
--project <KEY> A project the session may reach (repeat it, or
|
|
96
|
+
separate keys with commas; 1 to 10)
|
|
97
|
+
--label <name> The session's name in buildIt.Social, 1 to 40
|
|
98
|
+
characters (default: the folder's name)
|
|
99
|
+
--folder <path> The folder (default: the working directory)
|
|
100
|
+
--api-url <url> BUILDIT_API_URL Agent API base URL
|
|
101
|
+
(default ${DEFAULT_API_URL})
|
|
102
|
+
--yes Save without asking "Is this you?" once it is
|
|
103
|
+
approved. For scripts only: unsafe if anyone else
|
|
104
|
+
could have seen the code, since whoever approves
|
|
105
|
+
it gets the session
|
|
106
|
+
-h, --help Show this help
|
|
107
|
+
|
|
108
|
+
Once approved, it says who approved it (the agent, its owner, the org, the
|
|
109
|
+
projects) and asks "Is this you?"; only "y" saves the token, and any other
|
|
110
|
+
answer cancels the session.
|
|
111
|
+
`,
|
|
112
|
+
disconnect: `Usage: buildit-mcp disconnect [options]
|
|
113
|
+
|
|
114
|
+
Cancels this folder's session in buildIt.Social and forgets its saved token.
|
|
115
|
+
If buildIt.Social can't be reached, the token is forgotten anyway: revoke
|
|
116
|
+
the session in the app then.
|
|
117
|
+
|
|
118
|
+
Options:
|
|
119
|
+
--folder <path> The folder (default: the working directory)
|
|
120
|
+
--api-url <url> BUILDIT_API_URL Agent API base URL
|
|
121
|
+
-h, --help Show this help
|
|
122
|
+
`,
|
|
123
|
+
status: `Usage: buildit-mcp status [options]
|
|
124
|
+
|
|
125
|
+
Shows the session saved for this folder: its agent, name, org and reach, and
|
|
126
|
+
whether buildIt.Social still accepts it.
|
|
127
|
+
|
|
128
|
+
Options:
|
|
129
|
+
--folder <path> The folder (default: the working directory)
|
|
130
|
+
--api-url <url> BUILDIT_API_URL Agent API base URL
|
|
131
|
+
-h, --help Show this help
|
|
132
|
+
`,
|
|
133
|
+
};
|
|
134
|
+
const SESSION_COMMANDS = ['connect', 'disconnect', 'status'];
|
|
46
135
|
const TRUE_VALUES = new Set(['1', 'true', 'yes', 'on']);
|
|
47
136
|
const FALSE_VALUES = new Set(['0', 'false', 'no', 'off', '']);
|
|
48
137
|
export function parseBoolean(name, raw, fallback) {
|
|
@@ -55,6 +144,73 @@ export function parseBoolean(name, raw, fallback) {
|
|
|
55
144
|
return false;
|
|
56
145
|
throw new ConfigError(`${name} must be true or false (got "${raw}").`);
|
|
57
146
|
}
|
|
147
|
+
/**
|
|
148
|
+
* The plugin's settings arrive as environment variables it fills in, so a
|
|
149
|
+
* value that is empty or doesn't parse keeps the default, with a warning,
|
|
150
|
+
* instead of stopping the server.
|
|
151
|
+
*/
|
|
152
|
+
function parseAsk(env, warnings) {
|
|
153
|
+
let questionsInApp = true;
|
|
154
|
+
const rawIn = env.BUILDIT_QUESTIONS_IN_APP?.trim() ?? '';
|
|
155
|
+
if (rawIn !== '') {
|
|
156
|
+
const value = rawIn.toLowerCase();
|
|
157
|
+
if (TRUE_VALUES.has(value))
|
|
158
|
+
questionsInApp = true;
|
|
159
|
+
else if (FALSE_VALUES.has(value))
|
|
160
|
+
questionsInApp = false;
|
|
161
|
+
else
|
|
162
|
+
warnings.push('BUILDIT_QUESTIONS_IN_APP is not true or false; questions go to buildIt.Social.');
|
|
163
|
+
}
|
|
164
|
+
let timeoutMinutes = DEFAULT_ASK_TIMEOUT_MINUTES;
|
|
165
|
+
const rawMinutes = env.BUILDIT_ASK_TIMEOUT_MINUTES?.trim() ?? '';
|
|
166
|
+
if (rawMinutes !== '') {
|
|
167
|
+
const n = Number(rawMinutes);
|
|
168
|
+
if (Number.isFinite(n) && n >= 1 && n <= 60) {
|
|
169
|
+
timeoutMinutes = Math.floor(n);
|
|
170
|
+
}
|
|
171
|
+
else {
|
|
172
|
+
warnings.push(`BUILDIT_ASK_TIMEOUT_MINUTES must be 1 to 60; using ${DEFAULT_ASK_TIMEOUT_MINUTES}.`);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
let maxParallelItems = DEFAULT_MAX_PARALLEL_ITEMS;
|
|
176
|
+
const rawParallel = env.BUILDIT_MAX_PARALLEL_ITEMS?.trim() ?? '';
|
|
177
|
+
if (rawParallel !== '') {
|
|
178
|
+
const n = Number(rawParallel);
|
|
179
|
+
if (Number.isFinite(n) && n >= 1 && n <= 10) {
|
|
180
|
+
maxParallelItems = Math.floor(n);
|
|
181
|
+
}
|
|
182
|
+
else {
|
|
183
|
+
warnings.push(`BUILDIT_MAX_PARALLEL_ITEMS must be 1 to 10; using ${DEFAULT_MAX_PARALLEL_ITEMS}.`);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
return { questionsInApp, timeoutMinutes, maxParallelItems };
|
|
187
|
+
}
|
|
188
|
+
/** The tool approval settings, read as the question settings are (a bad value keeps the default). */
|
|
189
|
+
function parseApprovals(env, warnings) {
|
|
190
|
+
let inApp = true;
|
|
191
|
+
const rawIn = env.BUILDIT_APPROVALS_IN_APP?.trim() ?? '';
|
|
192
|
+
if (rawIn !== '') {
|
|
193
|
+
const value = rawIn.toLowerCase();
|
|
194
|
+
if (TRUE_VALUES.has(value))
|
|
195
|
+
inApp = true;
|
|
196
|
+
else if (FALSE_VALUES.has(value))
|
|
197
|
+
inApp = false;
|
|
198
|
+
else
|
|
199
|
+
warnings.push('BUILDIT_APPROVALS_IN_APP is not true or false; tool approvals go to buildIt.Social.');
|
|
200
|
+
}
|
|
201
|
+
let timeoutMinutes = DEFAULT_APPROVAL_TIMEOUT_MINUTES;
|
|
202
|
+
const rawMinutes = env.BUILDIT_APPROVAL_TIMEOUT_MINUTES?.trim() ?? '';
|
|
203
|
+
if (rawMinutes !== '') {
|
|
204
|
+
const n = Number(rawMinutes);
|
|
205
|
+
if (Number.isFinite(n) && n >= 1 && n <= 60) {
|
|
206
|
+
timeoutMinutes = Math.floor(n);
|
|
207
|
+
}
|
|
208
|
+
else {
|
|
209
|
+
warnings.push(`BUILDIT_APPROVAL_TIMEOUT_MINUTES must be 1 to 60; using ${DEFAULT_APPROVAL_TIMEOUT_MINUTES}.`);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
return { inApp, timeoutMinutes };
|
|
213
|
+
}
|
|
58
214
|
function splitList(raw) {
|
|
59
215
|
return [
|
|
60
216
|
...new Set(raw
|
|
@@ -90,6 +246,24 @@ const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '::1', '[::1]']);
|
|
|
90
246
|
export function isLoopbackHost(host) {
|
|
91
247
|
return LOOPBACK_HOSTS.has(host.toLowerCase());
|
|
92
248
|
}
|
|
249
|
+
/** The agent API's path on a buildIt.Social host. */
|
|
250
|
+
export const AGENT_API_PATH = '/functions/v1/agent-api';
|
|
251
|
+
/**
|
|
252
|
+
* The note shown once when an API address is a bare host (no path), which
|
|
253
|
+
* parseApiUrl completes with the agent API's path; undefined otherwise.
|
|
254
|
+
*/
|
|
255
|
+
export function apiUrlNote(raw) {
|
|
256
|
+
if (raw === undefined)
|
|
257
|
+
return undefined;
|
|
258
|
+
try {
|
|
259
|
+
if (new URL(raw.trim()).pathname !== '/')
|
|
260
|
+
return undefined;
|
|
261
|
+
}
|
|
262
|
+
catch {
|
|
263
|
+
return undefined;
|
|
264
|
+
}
|
|
265
|
+
return `The API address ${raw.trim()} has no path, so buildit-mcp uses ${parseApiUrl(raw)}, the agent API on that host.`;
|
|
266
|
+
}
|
|
93
267
|
export function parseApiUrl(raw) {
|
|
94
268
|
let url;
|
|
95
269
|
try {
|
|
@@ -108,6 +282,9 @@ export function parseApiUrl(raw) {
|
|
|
108
282
|
if (url.protocol !== 'https:' && !(url.protocol === 'http:' && loopback)) {
|
|
109
283
|
throw new ConfigError('BUILDIT_API_URL must use https:// (plain http:// is only allowed for localhost).');
|
|
110
284
|
}
|
|
285
|
+
// A bare host means its agent API.
|
|
286
|
+
if (url.pathname === '/')
|
|
287
|
+
url.pathname = AGENT_API_PATH;
|
|
111
288
|
return url.toString().replace(/\/+$/, '');
|
|
112
289
|
}
|
|
113
290
|
const HOSTNAME = /^(\[[0-9a-f:.]+\]|[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*)$/;
|
|
@@ -166,15 +343,120 @@ function parseLogLevel(raw) {
|
|
|
166
343
|
function nonEmpty(value) {
|
|
167
344
|
return value === undefined || value.trim() === '' ? undefined : value;
|
|
168
345
|
}
|
|
346
|
+
const PROJECT_KEY = /^[A-Za-z][A-Za-z0-9]{1,5}$/;
|
|
347
|
+
export const LABEL_MAX_CHARS = 40;
|
|
348
|
+
/** Control, direction and zero-width characters (a RegExp character class), which a session label may not hold. */
|
|
349
|
+
export const FORBIDDEN_CHARS = '[\\u0000-\\u001F\\u007F-\\u009F\\u200B-\\u200F\\u202A-\\u202E\\u2060-\\u2069\\uFEFF]';
|
|
350
|
+
/** Project keys from --project flags (each may list several, comma-separated). */
|
|
351
|
+
export function parseProjects(values) {
|
|
352
|
+
const keys = [...new Set(values.flatMap((v) => splitList(v)).map((k) => k.toUpperCase()))];
|
|
353
|
+
if (keys.length === 0) {
|
|
354
|
+
throw new ConfigError('Name the project the session may reach with --project <KEY>, for example --project DEMO.');
|
|
355
|
+
}
|
|
356
|
+
const bad = keys.filter((k) => !PROJECT_KEY.test(k));
|
|
357
|
+
if (bad.length > 0) {
|
|
358
|
+
throw new ConfigError(`Not a project key: ${bad.join(', ')}. Keys are 2 to 6 letters and digits, starting with a letter (for example DEMO).`);
|
|
359
|
+
}
|
|
360
|
+
if (keys.length > 10)
|
|
361
|
+
throw new ConfigError('A session may reach at most 10 projects.');
|
|
362
|
+
return keys;
|
|
363
|
+
}
|
|
364
|
+
/** The --label given, checked; or the folder's name, made to fit. */
|
|
365
|
+
export function sessionLabel(raw, folder) {
|
|
366
|
+
if (raw !== undefined) {
|
|
367
|
+
const label = raw.trim();
|
|
368
|
+
if (label.length === 0 || Array.from(label).length > LABEL_MAX_CHARS) {
|
|
369
|
+
throw new ConfigError(`The label must be 1 to ${LABEL_MAX_CHARS} characters.`);
|
|
370
|
+
}
|
|
371
|
+
if (new RegExp(FORBIDDEN_CHARS, 'u').test(label)) {
|
|
372
|
+
throw new ConfigError('The label may not hold control, direction or invisible characters.');
|
|
373
|
+
}
|
|
374
|
+
return label;
|
|
375
|
+
}
|
|
376
|
+
const name = Array.from(basename(folder).replace(new RegExp(FORBIDDEN_CHARS, 'gu'), '').trim())
|
|
377
|
+
.slice(0, LABEL_MAX_CHARS)
|
|
378
|
+
.join('')
|
|
379
|
+
.trim();
|
|
380
|
+
return name.length > 0 ? name : 'session';
|
|
381
|
+
}
|
|
382
|
+
/** The folder a session belongs to: --folder, else BUILDIT_FOLDER, else the working directory. */
|
|
383
|
+
function folderOf(flag, env, cwd) {
|
|
384
|
+
const raw = typeof flag === 'string' ? flag : nonEmpty(env.BUILDIT_FOLDER);
|
|
385
|
+
if (raw !== undefined && raw.trim() === '')
|
|
386
|
+
throw new ConfigError('--folder must not be empty.');
|
|
387
|
+
return resolve(cwd, raw?.trim() ?? '.');
|
|
388
|
+
}
|
|
389
|
+
function parseSessionCommand(command, argv, env, cwd, secrets) {
|
|
390
|
+
let values;
|
|
391
|
+
try {
|
|
392
|
+
({ values } = parseArgs({
|
|
393
|
+
args: [...argv],
|
|
394
|
+
strict: true,
|
|
395
|
+
allowPositionals: false,
|
|
396
|
+
options: {
|
|
397
|
+
'api-url': { type: 'string' },
|
|
398
|
+
folder: { type: 'string' },
|
|
399
|
+
help: { type: 'boolean', short: 'h' },
|
|
400
|
+
...(command === 'connect'
|
|
401
|
+
? {
|
|
402
|
+
project: { type: 'string', multiple: true },
|
|
403
|
+
label: { type: 'string' },
|
|
404
|
+
yes: { type: 'boolean' },
|
|
405
|
+
}
|
|
406
|
+
: {}),
|
|
407
|
+
},
|
|
408
|
+
}));
|
|
409
|
+
}
|
|
410
|
+
catch (err) {
|
|
411
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
412
|
+
throw new ConfigError(`${scrubString(message, secrets)} Run "buildit-mcp ${command} --help" for the options.`);
|
|
413
|
+
}
|
|
414
|
+
if (values.help === true)
|
|
415
|
+
return { kind: 'help', command };
|
|
416
|
+
const apiFlag = values['api-url'];
|
|
417
|
+
const apiRaw = (typeof apiFlag === 'string' ? apiFlag : nonEmpty(env.BUILDIT_API_URL)) ?? DEFAULT_API_URL;
|
|
418
|
+
const apiUrl = parseApiUrl(apiRaw);
|
|
419
|
+
const note = apiUrlNote(apiRaw);
|
|
420
|
+
const folder = folderOf(values.folder, env, cwd);
|
|
421
|
+
if (command !== 'connect') {
|
|
422
|
+
return {
|
|
423
|
+
kind: command,
|
|
424
|
+
options: {
|
|
425
|
+
apiUrl,
|
|
426
|
+
folder,
|
|
427
|
+
apiUrlFlag: typeof apiFlag === 'string',
|
|
428
|
+
...(note === undefined ? {} : { apiUrlNote: note }),
|
|
429
|
+
},
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
const projectFlags = values.project;
|
|
433
|
+
const projects = parseProjects(Array.isArray(projectFlags) ? projectFlags : []);
|
|
434
|
+
const label = sessionLabel(typeof values.label === 'string' ? values.label : undefined, folder);
|
|
435
|
+
return {
|
|
436
|
+
kind: 'connect',
|
|
437
|
+
options: {
|
|
438
|
+
apiUrl,
|
|
439
|
+
folder,
|
|
440
|
+
projects,
|
|
441
|
+
label,
|
|
442
|
+
yes: values.yes === true,
|
|
443
|
+
...(note === undefined ? {} : { apiUrlNote: note }),
|
|
444
|
+
},
|
|
445
|
+
};
|
|
446
|
+
}
|
|
169
447
|
/**
|
|
170
448
|
* Parses flags and environment variables. Throws ConfigError with a message
|
|
171
449
|
* that is safe to print (it never contains the token).
|
|
172
450
|
*/
|
|
173
|
-
export function parseCli(argv, env) {
|
|
451
|
+
export function parseCli(argv, env, cwd = process.cwd()) {
|
|
174
452
|
const secrets = env.BUILDIT_TOKEN ? [env.BUILDIT_TOKEN] : [];
|
|
175
453
|
if (argv.some((a) => a === '--token' || a.startsWith('--token='))) {
|
|
176
454
|
throw new ConfigError('The token is never accepted as a flag (other processes can read flags). Set the BUILDIT_TOKEN environment variable instead.');
|
|
177
455
|
}
|
|
456
|
+
const command = SESSION_COMMANDS.find((c) => c === argv[0]);
|
|
457
|
+
if (command !== undefined) {
|
|
458
|
+
return parseSessionCommand(command, argv.slice(1), env, cwd, secrets);
|
|
459
|
+
}
|
|
178
460
|
let values;
|
|
179
461
|
try {
|
|
180
462
|
({ values } = parseArgs({
|
|
@@ -193,6 +475,9 @@ export function parseCli(argv, env) {
|
|
|
193
475
|
'allowed-hosts': { type: 'string' },
|
|
194
476
|
'allowed-origins': { type: 'string' },
|
|
195
477
|
'allow-any-host': { type: 'boolean' },
|
|
478
|
+
channel: { type: 'boolean' },
|
|
479
|
+
folder: { type: 'string' },
|
|
480
|
+
'from-plugin': { type: 'boolean' },
|
|
196
481
|
help: { type: 'boolean', short: 'h' },
|
|
197
482
|
version: { type: 'boolean', short: 'v' },
|
|
198
483
|
},
|
|
@@ -213,9 +498,23 @@ export function parseCli(argv, env) {
|
|
|
213
498
|
const warnings = [];
|
|
214
499
|
const http = values.http === true || parseBoolean('BUILDIT_HTTP', env.BUILDIT_HTTP, false);
|
|
215
500
|
const mode = http ? 'http' : 'stdio';
|
|
216
|
-
const
|
|
501
|
+
const channelMode = values.channel === true || parseBoolean('BUILDIT_CHANNEL', env.BUILDIT_CHANNEL, false);
|
|
502
|
+
if (channelMode && mode === 'http') {
|
|
503
|
+
throw new ConfigError('Channel mode (--channel) runs over stdio only: Claude Code starts it as a local server.');
|
|
504
|
+
}
|
|
505
|
+
const apiRaw = str('api-url', 'BUILDIT_API_URL') ?? DEFAULT_API_URL;
|
|
506
|
+
const apiUrl = parseApiUrl(apiRaw);
|
|
507
|
+
const apiNote = apiUrlNote(apiRaw);
|
|
508
|
+
if (apiNote !== undefined)
|
|
509
|
+
warnings.push(apiNote);
|
|
217
510
|
const toolsetsRaw = str('toolsets', 'BUILDIT_TOOLSETS');
|
|
218
|
-
|
|
511
|
+
// Channel mode turns every toolset on by default: approvals need destructive and admin (the
|
|
512
|
+
// session's own scopes still limit what works).
|
|
513
|
+
const toolsets = toolsetsRaw === undefined
|
|
514
|
+
? channelMode
|
|
515
|
+
? [...TOOLSET_NAMES]
|
|
516
|
+
: [...DEFAULT_TOOLSETS]
|
|
517
|
+
: parseToolsets(toolsetsRaw);
|
|
219
518
|
const readOnly = values['read-only'] === true || parseBoolean('BUILDIT_READ_ONLY', env.BUILDIT_READ_ONLY, false);
|
|
220
519
|
const excludeRaw = str('exclude-tools', 'BUILDIT_EXCLUDE_TOOLS');
|
|
221
520
|
const excludeTools = excludeRaw === undefined ? [] : parseToolNames(excludeRaw, 'BUILDIT_EXCLUDE_TOOLS');
|
|
@@ -236,8 +535,29 @@ export function parseCli(argv, env) {
|
|
|
236
535
|
httpConfig.allowedOrigins = parseHostList(originsRaw, 'BUILDIT_ALLOWED_ORIGINS', true);
|
|
237
536
|
}
|
|
238
537
|
let token;
|
|
538
|
+
let channel;
|
|
239
539
|
const rawToken = env.BUILDIT_TOKEN;
|
|
240
|
-
if (
|
|
540
|
+
if (channelMode) {
|
|
541
|
+
channel = {
|
|
542
|
+
folder: folderOf(values.folder, env, cwd),
|
|
543
|
+
tokenSource: 'store',
|
|
544
|
+
apiUrlFlag: typeof values['api-url'] === 'string',
|
|
545
|
+
fromPlugin: values['from-plugin'] === true || nonEmpty(env.CLAUDE_PLUGIN_ROOT) !== undefined,
|
|
546
|
+
};
|
|
547
|
+
const envToken = rawToken?.trim() ?? '';
|
|
548
|
+
if (envToken.startsWith('buildit_ags_')) {
|
|
549
|
+
if (/\s/.test(envToken)) {
|
|
550
|
+
throw new ConfigError('BUILDIT_TOKEN contains whitespace; check that it was copied whole.');
|
|
551
|
+
}
|
|
552
|
+
token = envToken;
|
|
553
|
+
channel.tokenSource = 'env';
|
|
554
|
+
}
|
|
555
|
+
else if (envToken !== '') {
|
|
556
|
+
// A user-level personal access token from the usual setup: the folder's session wins.
|
|
557
|
+
warnings.push('BUILDIT_TOKEN is not a session token, so channel mode ignores it and uses the session that "buildit-mcp connect" saved for the folder.');
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
else if (mode === 'stdio') {
|
|
241
561
|
if (rawToken === undefined || rawToken.trim() === '') {
|
|
242
562
|
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
563
|
}
|
|
@@ -245,7 +565,7 @@ export function parseCli(argv, env) {
|
|
|
245
565
|
if (/\s/.test(token)) {
|
|
246
566
|
throw new ConfigError('BUILDIT_TOKEN contains whitespace; check that it was copied whole.');
|
|
247
567
|
}
|
|
248
|
-
if (!token.startsWith('buildit_pat_')) {
|
|
568
|
+
if (!token.startsWith('buildit_pat_') && !token.startsWith('buildit_ags_')) {
|
|
249
569
|
warnings.push('BUILDIT_TOKEN does not start with "buildit_pat_"; buildIt.Social personal access tokens do.');
|
|
250
570
|
}
|
|
251
571
|
}
|
|
@@ -269,6 +589,9 @@ export function parseCli(argv, env) {
|
|
|
269
589
|
mode,
|
|
270
590
|
apiUrl,
|
|
271
591
|
...(token === undefined ? {} : { token }),
|
|
592
|
+
...(channel === undefined ? {} : { channel }),
|
|
593
|
+
ask: parseAsk(env, warnings),
|
|
594
|
+
approvals: parseApprovals(env, warnings),
|
|
272
595
|
toolsets,
|
|
273
596
|
readOnly,
|
|
274
597
|
excludeTools,
|
package/dist/errors.js
CHANGED
|
@@ -83,6 +83,12 @@ const RATE_BUCKETS = {
|
|
|
83
83
|
token_writes_per_day: 'writes per day for this token',
|
|
84
84
|
org_requests_per_minute: 'requests per minute for the whole org, across all its tokens',
|
|
85
85
|
user_requests_per_minute: 'requests per minute for your account, across all your tokens',
|
|
86
|
+
session_polls_per_minute: 'polls per minute for this session',
|
|
87
|
+
session_typing_per_minute: 'typing notices per minute for this session',
|
|
88
|
+
session_approvals_per_minute: 'tool approval calls per minute for this session',
|
|
89
|
+
connect_starts_per_hour: 'connections started per hour from this address',
|
|
90
|
+
connect_pending_grants: 'connections waiting for approval on this server',
|
|
91
|
+
connect_pending_per_address: 'connections waiting for approval from this network',
|
|
86
92
|
};
|
|
87
93
|
/** The release operations, as plan_release's actions. */
|
|
88
94
|
const RELEASE_ACTIONS = {
|
|
@@ -316,6 +322,38 @@ export function describeApiError(err) {
|
|
|
316
322
|
return lines.join('\n');
|
|
317
323
|
}
|
|
318
324
|
/** A tool result for any failure inside a tool handler. */
|
|
325
|
+
/** The codes that mean an agent session is over: its token is no longer accepted. */
|
|
326
|
+
export const SESSION_OVER_CODES = new Set([
|
|
327
|
+
'token_revoked',
|
|
328
|
+
'token_expired',
|
|
329
|
+
'token_invalid',
|
|
330
|
+
]);
|
|
331
|
+
const SESSION_OVER_WHY = {
|
|
332
|
+
token_revoked: 'it was revoked',
|
|
333
|
+
token_expired: 'it expired after 30 days unused',
|
|
334
|
+
token_invalid: 'buildIt.Social no longer recognises its token',
|
|
335
|
+
};
|
|
336
|
+
/**
|
|
337
|
+
* What to tell the agent once a session token is refused for good: a
|
|
338
|
+
* session is reconnected with `connect`, never with a new personal access
|
|
339
|
+
* token.
|
|
340
|
+
*/
|
|
341
|
+
export function sessionEndedText(code) {
|
|
342
|
+
const why = SESSION_OVER_WHY[code] ?? code;
|
|
343
|
+
return `This buildIt.Social session has ended (${why}). In this folder run \`buildit-mcp connect --project <KEY>\`, approve it, then restart Claude Code. Tell the person; retrying won't help.`;
|
|
344
|
+
}
|
|
345
|
+
/** A tool result for a call made after (or refused because) the session ended. */
|
|
346
|
+
export function sessionEndedResult(code) {
|
|
347
|
+
return {
|
|
348
|
+
isError: true,
|
|
349
|
+
content: [
|
|
350
|
+
{
|
|
351
|
+
type: 'text',
|
|
352
|
+
text: `buildit-mcp error: session_ended (${code})\n${sessionEndedText(code)}`,
|
|
353
|
+
},
|
|
354
|
+
],
|
|
355
|
+
};
|
|
356
|
+
}
|
|
319
357
|
export function toolErrorResult(err, requestId) {
|
|
320
358
|
if (err instanceof ApiError) {
|
|
321
359
|
return { isError: true, content: [{ type: 'text', text: describeApiError(err) }] };
|
package/dist/index.js
CHANGED
|
@@ -1,9 +1,16 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
|
-
* The buildit-mcp command. stdio by default; Streamable HTTP with --http
|
|
3
|
+
* The buildit-mcp command. stdio by default; Streamable HTTP with --http;
|
|
4
|
+
* a Claude Code channel with --channel; and the session commands
|
|
5
|
+
* (connect, disconnect, status), which run in a terminal.
|
|
4
6
|
*/
|
|
5
|
-
import {
|
|
7
|
+
import { join } from 'node:path';
|
|
8
|
+
import { createInterface } from 'node:readline/promises';
|
|
9
|
+
import { acquireChannelLock, defaultLockDir } from './channel/lock.js';
|
|
10
|
+
import { runConnect, runDisconnect, runStatus } from './cli/commands.js';
|
|
11
|
+
import { COMMAND_USAGE, ConfigError, parseApiUrl, parseCli, USAGE } from './config.js';
|
|
6
12
|
import { createLogger } from './log.js';
|
|
13
|
+
import { forgetSession, openTokenStore, savedApiUrl, sessionKey } from './secrets/keychain.js';
|
|
7
14
|
import { CATALOG } from './toolsets/catalog.js';
|
|
8
15
|
import { unknownToolNames } from './toolsets/registry.js';
|
|
9
16
|
import { startHttp } from './transports/http.js';
|
|
@@ -23,19 +30,89 @@ async function main() {
|
|
|
23
30
|
throw err;
|
|
24
31
|
}
|
|
25
32
|
if (action.kind === 'help') {
|
|
26
|
-
process.stdout.write(USAGE);
|
|
33
|
+
process.stdout.write(action.command ? COMMAND_USAGE[action.command] : USAGE);
|
|
27
34
|
return;
|
|
28
35
|
}
|
|
29
36
|
if (action.kind === 'version') {
|
|
30
37
|
process.stdout.write(`${SERVER_VERSION}\n`);
|
|
31
38
|
return;
|
|
32
39
|
}
|
|
33
|
-
|
|
40
|
+
if (action.kind === 'connect' || action.kind === 'disconnect' || action.kind === 'status') {
|
|
41
|
+
if (action.options.apiUrlNote !== undefined) {
|
|
42
|
+
process.stdout.write(`${action.options.apiUrlNote}\n`);
|
|
43
|
+
}
|
|
44
|
+
// A terminal command says what happened in plain text; logs stay quiet.
|
|
45
|
+
const store = await openTokenStore({
|
|
46
|
+
warn: (message) => process.stderr.write(`${SERVER_NAME}: ${message}\n`),
|
|
47
|
+
});
|
|
48
|
+
const io = {
|
|
49
|
+
out: (text) => process.stdout.write(text),
|
|
50
|
+
err: (text) => process.stderr.write(`${SERVER_NAME}: ${text}`),
|
|
51
|
+
store,
|
|
52
|
+
logger: createLogger({ level: 'error' }),
|
|
53
|
+
ask: async (question) => {
|
|
54
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
55
|
+
// Without a terminal or input (stdin closed), the answer is empty: not saved.
|
|
56
|
+
const closed = new Promise((resolve) => rl.once('close', () => {
|
|
57
|
+
resolve('');
|
|
58
|
+
}));
|
|
59
|
+
try {
|
|
60
|
+
return await Promise.race([rl.question(question), closed]);
|
|
61
|
+
}
|
|
62
|
+
finally {
|
|
63
|
+
rl.close();
|
|
64
|
+
}
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
process.exitCode =
|
|
68
|
+
action.kind === 'connect'
|
|
69
|
+
? await runConnect(action.options, io)
|
|
70
|
+
: action.kind === 'disconnect'
|
|
71
|
+
? await runDisconnect(action.options, io)
|
|
72
|
+
: await runStatus(action.options, io);
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
let { config } = action;
|
|
76
|
+
const early = [];
|
|
77
|
+
let clearSaved;
|
|
78
|
+
if (config.channel !== undefined) {
|
|
79
|
+
// Channel mode: the API connect saved for the folder, unless --api-url says otherwise.
|
|
80
|
+
const store = await openTokenStore({ warn: (message) => early.push(message) });
|
|
81
|
+
const saved = config.channel.apiUrlFlag
|
|
82
|
+
? undefined
|
|
83
|
+
: await savedApiUrl(store, config.channel.folder);
|
|
84
|
+
if (saved !== undefined) {
|
|
85
|
+
try {
|
|
86
|
+
config = { ...config, apiUrl: parseApiUrl(saved) };
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
early.push('The API saved for this folder is not valid; using BUILDIT_API_URL or the default.');
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
if (config.channel !== undefined && config.token === undefined) {
|
|
94
|
+
// Channel mode without a session token in BUILDIT_TOKEN: the token connect saved for the folder.
|
|
95
|
+
const store = await openTokenStore({ warn: () => undefined });
|
|
96
|
+
const key = sessionKey(config.channel.folder, config.apiUrl);
|
|
97
|
+
const token = await store.get(key);
|
|
98
|
+
if (token === undefined) {
|
|
99
|
+
process.stderr.write(`${SERVER_NAME}: No buildIt.Social session is saved for ${config.channel.folder}. In a terminal in that folder, run: npx -y @kammcs/buildit-mcp@latest connect --project <KEY>\n`);
|
|
100
|
+
process.exitCode = 2;
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
const folder = config.channel.folder;
|
|
104
|
+
const apiUrl = config.apiUrl;
|
|
105
|
+
config = { ...config, token };
|
|
106
|
+
clearSaved = async () => {
|
|
107
|
+
// The token, and the record of its deployment, as disconnect does.
|
|
108
|
+
await forgetSession(store, folder, apiUrl);
|
|
109
|
+
};
|
|
110
|
+
}
|
|
34
111
|
const logger = createLogger({
|
|
35
112
|
level: config.logLevel,
|
|
36
113
|
...(config.token ? { secrets: [config.token] } : {}),
|
|
37
114
|
});
|
|
38
|
-
for (const warning of config.warnings)
|
|
115
|
+
for (const warning of [...config.warnings, ...early])
|
|
39
116
|
logger.warn(warning);
|
|
40
117
|
const unknown = unknownToolNames(CATALOG, config.excludeTools);
|
|
41
118
|
if (unknown.length > 0) {
|
|
@@ -44,12 +121,51 @@ async function main() {
|
|
|
44
121
|
logger.info('starting', {
|
|
45
122
|
version: SERVER_VERSION,
|
|
46
123
|
mode: config.mode,
|
|
124
|
+
channel: config.channel !== undefined,
|
|
47
125
|
api_url: config.apiUrl,
|
|
48
126
|
toolsets: config.toolsets,
|
|
49
127
|
read_only: config.readOnly,
|
|
50
128
|
});
|
|
51
129
|
if (config.mode === 'stdio') {
|
|
52
|
-
|
|
130
|
+
// One channel server per folder: the plugin's first, else the first to start.
|
|
131
|
+
let channelOwner = true;
|
|
132
|
+
let channelLock;
|
|
133
|
+
if (config.channel !== undefined) {
|
|
134
|
+
try {
|
|
135
|
+
channelLock = acquireChannelLock(config.channel.folder, config.apiUrl, {
|
|
136
|
+
plugin: config.channel.fromPlugin,
|
|
137
|
+
});
|
|
138
|
+
channelOwner = channelLock !== undefined;
|
|
139
|
+
if (channelLock?.preempted !== undefined) {
|
|
140
|
+
logger.warn("another buildit server without the plugin was receiving this folder's messages; the plugin's server takes them over, and that one stops polling. Remove it (claude mcp remove <name>)", { other_pid: channelLock.preempted });
|
|
141
|
+
}
|
|
142
|
+
else if (channelLock !== undefined && !channelLock.plugin) {
|
|
143
|
+
logger.info("this server receives the folder's messages; Claude Code shows them only when it was started with --dangerously-load-development-channels server:<name> (or use the buildit plugin)");
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
catch (err) {
|
|
147
|
+
// Without a lock folder, serve as the channel (as before locks existed).
|
|
148
|
+
logger.warn('could not take the channel lock; serving as the channel', { error: err });
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
const handle = startStdio(config, logger, {
|
|
152
|
+
channelOwner,
|
|
153
|
+
...(channelLock === undefined ? {} : { channelLock }),
|
|
154
|
+
// The folder's open tool approvals, next to its lock files.
|
|
155
|
+
...(config.channel !== undefined && channelOwner
|
|
156
|
+
? {
|
|
157
|
+
approvalJournal: join(defaultLockDir(), `${sessionKey(config.channel.folder, config.apiUrl)}.approvals.json`),
|
|
158
|
+
}
|
|
159
|
+
: {}),
|
|
160
|
+
onSessionEnded: async (reason) => {
|
|
161
|
+
if (reason !== 'token_revoked' && reason !== 'token_expired')
|
|
162
|
+
return;
|
|
163
|
+
if (clearSaved === undefined)
|
|
164
|
+
return;
|
|
165
|
+
await clearSaved().catch(() => undefined);
|
|
166
|
+
logger.warn('the session was revoked or has expired, so its saved token is cleared; run "buildit-mcp connect" in the folder to connect again');
|
|
167
|
+
},
|
|
168
|
+
});
|
|
53
169
|
const stop = () => {
|
|
54
170
|
void handle.close().finally(() => process.exit(0));
|
|
55
171
|
};
|
package/dist/log.js
CHANGED
|
@@ -14,13 +14,14 @@ const REDACTED = '[redacted]';
|
|
|
14
14
|
/** Field names whose values are never logged. */
|
|
15
15
|
const SECRET_KEY = /(authorization|token|secret|password|api[-_]?key|cookie)/i;
|
|
16
16
|
/**
|
|
17
|
-
* Bearer credentials and buildIt personal access tokens, wherever
|
|
18
|
-
* in a string. The token pattern has no word boundary in front, so a token
|
|
17
|
+
* Bearer credentials and buildIt personal access and session tokens, wherever
|
|
18
|
+
* they appear in a string. The token pattern has no word boundary in front, so a token
|
|
19
19
|
* glued to other text (`MY_buildit_pat_...`, `x1buildit_pat_...`) is caught too.
|
|
20
20
|
*/
|
|
21
21
|
const SECRET_PATTERNS = [
|
|
22
22
|
/\bBearer\s+[^\s"',;]+/gi,
|
|
23
23
|
/buildit_pat_[A-Za-z0-9_]+/g,
|
|
24
|
+
/buildit_ags_[A-Za-z0-9_]+/g,
|
|
24
25
|
];
|
|
25
26
|
export function scrubString(value, secrets = []) {
|
|
26
27
|
let out = value;
|