@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.
Files changed (40) hide show
  1. package/CHANGELOG.md +116 -0
  2. package/README.md +252 -32
  3. package/SECURITY.md +1 -1
  4. package/dist/api/client.js +5 -1
  5. package/dist/api/generated/operations.js +661 -4
  6. package/dist/api/generated/schemas.js +680 -2
  7. package/dist/api/generated/strict.js +799 -2
  8. package/dist/channel/api.js +128 -0
  9. package/dist/channel/approvals.js +259 -0
  10. package/dist/channel/item-events.js +138 -0
  11. package/dist/channel/lock.js +181 -0
  12. package/dist/channel/loop.js +776 -0
  13. package/dist/channel/mask.js +365 -0
  14. package/dist/channel/threads.js +60 -0
  15. package/dist/channel/tool-ring.js +124 -0
  16. package/dist/channel/typing.js +150 -0
  17. package/dist/channel/waiters.js +229 -0
  18. package/dist/channel/workers.js +66 -0
  19. package/dist/cli/claude-config.js +100 -0
  20. package/dist/cli/commands.js +410 -0
  21. package/dist/config.js +329 -6
  22. package/dist/errors.js +38 -0
  23. package/dist/index.js +122 -6
  24. package/dist/log.js +3 -2
  25. package/dist/secrets/keychain.js +273 -0
  26. package/dist/server.js +53 -5
  27. package/dist/tools/approve.js +791 -0
  28. package/dist/tools/ask.js +898 -0
  29. package/dist/tools/chat.js +98 -1
  30. package/dist/tools/items-read.js +5 -3
  31. package/dist/tools/items-write.js +66 -15
  32. package/dist/tools/plans.js +221 -14
  33. package/dist/tools/reply.js +138 -0
  34. package/dist/tools/shared.js +10 -1
  35. package/dist/tools/threads.js +123 -0
  36. package/dist/tools/whoami.js +17 -3
  37. package/dist/toolsets/catalog.js +3 -1
  38. package/dist/toolsets/registry.js +2 -0
  39. package/dist/transports/stdio.js +260 -6
  40. 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 apiUrl = parseApiUrl(str('api-url', 'BUILDIT_API_URL') ?? DEFAULT_API_URL);
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
- const toolsets = toolsetsRaw === undefined ? [...DEFAULT_TOOLSETS] : parseToolsets(toolsetsRaw);
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 (mode === 'stdio') {
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 { ConfigError, parseCli, USAGE } from './config.js';
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
- const { config } = action;
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
- const handle = startStdio(config, logger);
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 they appear
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;