@kammcs/buildit-mcp 0.2.0 → 0.8.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 (43) hide show
  1. package/CHANGELOG.md +149 -0
  2. package/README.md +273 -32
  3. package/SECURITY.md +1 -1
  4. package/dist/api/client.js +5 -1
  5. package/dist/api/generated/operations.js +840 -11
  6. package/dist/api/generated/schemas.js +936 -3
  7. package/dist/api/generated/strict.js +1088 -3
  8. package/dist/capabilities.js +236 -0
  9. package/dist/channel/api.js +159 -0
  10. package/dist/channel/approvals.js +259 -0
  11. package/dist/channel/handoffs.js +190 -0
  12. package/dist/channel/item-events.js +164 -0
  13. package/dist/channel/lock.js +181 -0
  14. package/dist/channel/loop.js +1034 -0
  15. package/dist/channel/mask.js +365 -0
  16. package/dist/channel/threads.js +60 -0
  17. package/dist/channel/tool-ring.js +124 -0
  18. package/dist/channel/typing.js +150 -0
  19. package/dist/channel/waiters.js +229 -0
  20. package/dist/channel/workers.js +66 -0
  21. package/dist/cli/claude-config.js +100 -0
  22. package/dist/cli/commands.js +438 -0
  23. package/dist/config.js +356 -6
  24. package/dist/errors.js +49 -1
  25. package/dist/index.js +122 -6
  26. package/dist/log.js +3 -2
  27. package/dist/secrets/keychain.js +273 -0
  28. package/dist/server.js +61 -5
  29. package/dist/tools/approve.js +791 -0
  30. package/dist/tools/ask.js +898 -0
  31. package/dist/tools/chat.js +98 -1
  32. package/dist/tools/handoff.js +409 -0
  33. package/dist/tools/items-read.js +6 -3
  34. package/dist/tools/items-write.js +77 -16
  35. package/dist/tools/plans.js +221 -14
  36. package/dist/tools/reply.js +138 -0
  37. package/dist/tools/shared.js +24 -1
  38. package/dist/tools/threads.js +149 -0
  39. package/dist/tools/whoami.js +53 -3
  40. package/dist/toolsets/catalog.js +3 -1
  41. package/dist/toolsets/registry.js +2 -0
  42. package/dist/transports/stdio.js +288 -12
  43. package/package.json +5 -1
@@ -1,10 +1,30 @@
1
- import { isJSONRPCRequest, } from '@modelcontextprotocol/server';
1
+ import { isJSONRPCNotification, isJSONRPCRequest, PROTOCOL_VERSION_META_KEY, ProtocolErrorCode, SUPPORTED_PROTOCOL_VERSIONS, } from '@modelcontextprotocol/server';
2
2
  import { serveStdio, StdioServerTransport, } from '@modelcontextprotocol/server/stdio';
3
3
  import { ApiClient } from '../api/client.js';
4
+ import { CHANNEL_NOTIFICATION, ChannelLoop, ChannelState, } from '../channel/loop.js';
4
5
  import { invalidatesIdentity } from '../identity.js';
5
6
  import { createSwitchableMcpServer, DEGRADED_LIST_TTL_MS, listedByPolicy, resolveListedTools, identityFromApi, } from '../server.js';
7
+ import { PREEMPT_WAIT_MS } from '../channel/lock.js';
8
+ import { TypingIndicator } from '../channel/typing.js';
9
+ import { ToolApprovals } from '../channel/approvals.js';
10
+ import { APPROVAL_HOOK_TOOLS, createApprovalHookTools, READ_ONLY_SAFE_HOOKS, createApproveTool, } from '../tools/approve.js';
11
+ import { createAskTool } from '../tools/ask.js';
12
+ import { createReplyTool, createTurnEndedTool } from '../tools/reply.js';
13
+ import { createListThreadsTool } from '../tools/threads.js';
14
+ import { createHandoffTools } from '../tools/handoff.js';
15
+ import { suggestedCapabilities } from '../capabilities.js';
6
16
  import { CATALOG } from '../toolsets/catalog.js';
7
17
  import { compareVersions, SERVER_VERSION } from '../version.js';
18
+ /**
19
+ * The plugin's AskUserQuestion and PermissionRequest hooks' timeout, in
20
+ * seconds (plugin/hooks/hooks.json). A card's wait ends a minute before it,
21
+ * so the answer arrives in time.
22
+ */
23
+ export const HOOK_TIMEOUT_S = 3600;
24
+ /** How long a hook's card waits: the configured minutes, a minute under the hook's timeout at most. */
25
+ export function hookWaitSeconds(minutes) {
26
+ return Math.min(minutes * 60, HOOK_TIMEOUT_S - 60);
27
+ }
8
28
  /** A known identity is read again at most this often. */
9
29
  export const IDENTITY_REFRESH_MS = 60_000;
10
30
  /** Methods whose answer depends on the token's scopes: they wait for a due refresh. */
@@ -111,13 +131,17 @@ export class StdioIdentity {
111
131
  class GatedTransport {
112
132
  inner;
113
133
  before;
134
+ closed;
114
135
  onclose;
115
136
  onerror;
116
137
  onmessage;
117
138
  queue = Promise.resolve();
118
- constructor(inner, before) {
139
+ constructor(inner, before,
140
+ /** Told when the connection closes (stdin ended, or close()). */
141
+ closed = () => undefined) {
119
142
  this.inner = inner;
120
143
  this.before = before;
144
+ this.closed = closed;
121
145
  }
122
146
  start() {
123
147
  this.inner.onmessage = (message, extra) => {
@@ -129,6 +153,71 @@ class GatedTransport {
129
153
  });
130
154
  };
131
155
  this.inner.onerror = (error) => this.onerror?.(error);
156
+ this.inner.onclose = () => {
157
+ this.closed();
158
+ this.onclose?.();
159
+ };
160
+ return this.inner.start();
161
+ }
162
+ send(message, options) {
163
+ return this.inner.send(message, options);
164
+ }
165
+ close() {
166
+ return this.inner.close();
167
+ }
168
+ }
169
+ /** Whether a message claims a protocol revision in its _meta (the 2026-07-28 way of opening). */
170
+ function claimsRevision(message) {
171
+ if (!isJSONRPCRequest(message) && !isJSONRPCNotification(message))
172
+ return false;
173
+ const params = message.params;
174
+ return params?._meta?.[PROTOCOL_VERSION_META_KEY] !== undefined;
175
+ }
176
+ /**
177
+ * Keeps a connection on the 2025 handshake (channel mode): until
178
+ * `initialize`, a request that claims a revision is answered with the
179
+ * unsupported-version error naming only the 2025-era revisions, which makes
180
+ * a client fall back to `initialize`; such a notification is dropped.
181
+ */
182
+ export class LegacyOnlyTransport {
183
+ inner;
184
+ onclose;
185
+ onerror;
186
+ onmessage;
187
+ initialized = false;
188
+ constructor(inner) {
189
+ this.inner = inner;
190
+ }
191
+ start() {
192
+ this.inner.onmessage = (message, extra) => {
193
+ if (!this.initialized && claimsRevision(message)) {
194
+ if (isJSONRPCRequest(message)) {
195
+ const meta = message.params._meta ?? {};
196
+ const requested = meta[PROTOCOL_VERSION_META_KEY];
197
+ void this.inner
198
+ .send({
199
+ jsonrpc: '2.0',
200
+ id: message.id,
201
+ error: {
202
+ code: ProtocolErrorCode.UnsupportedProtocolVersion,
203
+ message: 'Unsupported protocol version: this channel server speaks the 2025 handshake.',
204
+ data: {
205
+ supported: [...SUPPORTED_PROTOCOL_VERSIONS],
206
+ requested: typeof requested === 'string' ? requested : 'unknown',
207
+ },
208
+ },
209
+ })
210
+ .catch((error) => {
211
+ this.onerror?.(error instanceof Error ? error : new Error(String(error)));
212
+ });
213
+ }
214
+ return;
215
+ }
216
+ if (isJSONRPCRequest(message) && message.method === 'initialize')
217
+ this.initialized = true;
218
+ this.onmessage?.(message, extra);
219
+ };
220
+ this.inner.onerror = (error) => this.onerror?.(error);
132
221
  this.inner.onclose = () => this.onclose?.();
133
222
  return this.inner.start();
134
223
  }
@@ -139,6 +228,68 @@ class GatedTransport {
139
228
  return this.inner.close();
140
229
  }
141
230
  }
231
+ /** The tools that post in a thread and stop its "is writing" themselves. */
232
+ const POSTING_TOOLS = new Set(['reply', 'ask', 'turn_ended', 'list_threads']);
233
+ /**
234
+ * A tool call is activity for "is writing": for the thread it names (its
235
+ * thread_id or card_thread, or an item's key that the session works on),
236
+ * else for the only working thread. A call that works on an active item's
237
+ * thread (a worker reading or changing its item) shows "is writing" there
238
+ * again; the posting tools stop it themselves.
239
+ */
240
+ function noteActivity(state, tool, args) {
241
+ const typing = state.typing;
242
+ if (typing === undefined)
243
+ return;
244
+ // The plugin's approval hooks: tool_seen counts the activity itself.
245
+ if (APPROVAL_HOOK_TOOLS.has(tool))
246
+ return;
247
+ const a = (args !== null && typeof args === 'object' ? args : {});
248
+ const str = (v) => typeof v === 'string' && v !== '' ? v : undefined;
249
+ const ref = str(a.thread_id) ?? str(a.card_thread) ?? str(a.item);
250
+ const key = ref?.startsWith('#') ? ref.slice(1) : ref;
251
+ const thread = key === undefined
252
+ ? undefined
253
+ : state.activeItems.has(key)
254
+ ? key
255
+ : state.items.get(key.toUpperCase());
256
+ if (thread === undefined || !state.activeItems.has(thread)) {
257
+ typing.activity(key !== undefined && thread === undefined ? key : undefined);
258
+ return;
259
+ }
260
+ if (POSTING_TOOLS.has(tool))
261
+ typing.activity(thread);
262
+ else
263
+ typing.work(thread);
264
+ }
265
+ /**
266
+ * The tools only channel mode has (reply, ask, list_threads, and the
267
+ * plugin's hooks: turn_ended, approve and the hooks that end approvals),
268
+ * under the operator's read-only and exclude rules.
269
+ */
270
+ function channelTools(policy, state, ask, approve) {
271
+ return [
272
+ createReplyTool(state),
273
+ createAskTool(state, ask),
274
+ createListThreadsTool(state),
275
+ createTurnEndedTool(state),
276
+ createApproveTool(state, approve),
277
+ ...createApprovalHookTools(state),
278
+ ...createHandoffTools(state),
279
+ ].filter((tool) => (!policy.readOnly || tool.annotations.readOnlyHint || READ_ONLY_SAFE_HOOKS.has(tool.name)) &&
280
+ !policy.excludeTools.includes(tool.name));
281
+ }
282
+ /**
283
+ * The channel tools this server's API serves: a tool that needs operations
284
+ * the API's /v1/meta leaves out is not listed (when the API doesn't say,
285
+ * every tool is, and an older one says so when called).
286
+ */
287
+ function servedTools(api, tools) {
288
+ const served = api.served?.operations;
289
+ if (served === undefined)
290
+ return [...tools];
291
+ return tools.filter((t) => (t.requiresOperations ?? []).every((op) => served.has(op)));
292
+ }
142
293
  export function startStdio(config, logger, options = {}) {
143
294
  if (config.token === undefined)
144
295
  throw new Error('stdio mode needs a token');
@@ -159,14 +310,49 @@ export function startStdio(config, logger, options = {}) {
159
310
  invalidations.listener();
160
311
  },
161
312
  });
162
- const identity = new StdioIdentity(() => resolveListedTools(identityFromApi(api), catalog, policy, logger), logger, options.now);
163
- invalidations.listener = () => {
164
- identity.invalidate();
313
+ const channel = config.channel !== undefined;
314
+ const state = new ChannelState();
315
+ state.isChannel = options.channelOwner ?? true;
316
+ state.folder = config.channel?.folder;
317
+ if (channel && state.isChannel) {
318
+ state.typing = new TypingIndicator({
319
+ api,
320
+ logger: logger.child({ component: 'typing' }),
321
+ ...options.typing,
322
+ });
323
+ state.approvals = new ToolApprovals({
324
+ logger: logger.child({ component: 'approvals' }),
325
+ journal: options.approvalJournal,
326
+ });
327
+ }
328
+ const extraTools = channel
329
+ ? channelTools(policy, state, {
330
+ questionsInApp: config.ask.questionsInApp,
331
+ hookWaitS: hookWaitSeconds(config.ask.timeoutMinutes),
332
+ ...options.ask,
333
+ }, {
334
+ approvalsInApp: config.approvals.inApp,
335
+ hookWaitS: hookWaitSeconds(config.approvals.timeoutMinutes),
336
+ ...options.approve,
337
+ })
338
+ : [];
339
+ // Once a channel's session has ended, the listing stays as it last was
340
+ // (without reply), so Claude keeps its tools, which then say why.
341
+ let lastKnown;
342
+ const isEnded = () => state.ended !== undefined;
343
+ const frozen = () => {
344
+ const base = lastKnown ?? {
345
+ ...listedByPolicy(catalog, policy),
346
+ identity: 'unverified',
347
+ };
348
+ return {
349
+ ...base,
350
+ tools: base.tools.filter((t) => !extraTools.includes(t) || t.runsAfterSessionEnd === true),
351
+ };
165
352
  };
166
- // Read the identity at startup.
167
- void identity.refresh();
168
- // Warn early when the API needs a newer server.
169
- void api
353
+ // Read what the API serves at startup: it warns when the API needs a newer server, and
354
+ // channel tools newer than the server (the handoffs) are left out of the listing.
355
+ const metaRead = api
170
356
  .getMeta()
171
357
  .then((meta) => {
172
358
  if (meta.min_mcp_version && compareVersions(SERVER_VERSION, meta.min_mcp_version) < 0) {
@@ -177,27 +363,117 @@ export function startStdio(config, logger, options = {}) {
177
363
  }
178
364
  })
179
365
  .catch(() => undefined);
366
+ const identity = new StdioIdentity(async () => {
367
+ if (isEnded())
368
+ return frozen();
369
+ const listed = await resolveListedTools(identityFromApi(api), catalog, policy, logger);
370
+ if (extraTools.length > 0)
371
+ await metaRead;
372
+ const served = servedTools(api, extraTools);
373
+ const shown = served.length > 0 ? { ...listed, tools: [...listed.tools, ...served] } : listed;
374
+ if (shown.identity === 'known')
375
+ lastKnown = shown;
376
+ // The session may have ended while the identity was read.
377
+ return isEnded() ? frozen() : shown;
378
+ }, logger, options.now);
379
+ invalidations.listener = () => {
380
+ identity.invalidate();
381
+ };
382
+ // Read the identity at startup.
383
+ void identity.refresh();
180
384
  // Everything the configuration allows is registered; the identity decides what is shown.
181
385
  const allowed = listedByPolicy(catalog, policy);
182
386
  const wire = options.stdin && options.stdout
183
387
  ? new StdioServerTransport(options.stdin, options.stdout)
184
388
  : new StdioServerTransport();
185
- return serveStdio(async () => {
389
+ let loop;
390
+ const stopLoop = () => loop?.stop() ?? Promise.resolve();
391
+ const handle = serveStdio(async () => {
186
392
  const listed = await identity.refresh();
187
393
  const switchable = createSwitchableMcpServer({
188
- tools: allowed.tools,
394
+ // What /v1/meta said is known by now (refresh waited for it): the instructions leave out
395
+ // the channel tools this API doesn't serve.
396
+ tools: [...allowed.tools, ...servedTools(api, extraTools)],
189
397
  resources: allowed.resources,
190
398
  prompts: allowed.prompts,
191
399
  api,
192
400
  logger,
401
+ channel,
402
+ ...(channel
403
+ ? {
404
+ channelState: state,
405
+ ...(config.channel?.fromPlugin === true
406
+ ? { lead: { maxParallelItems: config.ask.maxParallelItems } }
407
+ : {}),
408
+ onToolCall: (tool, args) => {
409
+ noteActivity(state, tool, args);
410
+ },
411
+ sessionEnded: () => state.ended,
412
+ onSessionOver: (code) => {
413
+ void loop?.end(code);
414
+ },
415
+ }
416
+ : {}),
193
417
  ...(listed.identity === 'known' ? {} : { toolsListTtlMs: DEGRADED_LIST_TTL_MS }),
194
418
  });
195
419
  identity.attach(switchable);
420
+ if (channel) {
421
+ const mcp = switchable.server.server;
422
+ // Notifications sent before the client has initialized would be lost.
423
+ mcp.oninitialized = () => {
424
+ if (loop !== undefined)
425
+ return;
426
+ if (!state.isChannel) {
427
+ logger.warn('another buildit server in this folder receives the messages from buildIt.Social (it holds the channel lock); this one serves its tools without the channel. Remove the extra server (claude mcp remove <name>; "buildit-mcp status" names them)');
428
+ return;
429
+ }
430
+ loop = new ChannelLoop({
431
+ api,
432
+ logger: logger.child({ component: 'channel' }),
433
+ state,
434
+ notify: (params) => mcp.notification({ method: CHANNEL_NOTIFICATION, params }),
435
+ // What this computer can do, as tag names only (BUILDIT_CAPABILITIES replaces it).
436
+ capabilities: () => suggestedCapabilities(config.channel?.capabilities),
437
+ ...(options.channelLock === undefined
438
+ ? {}
439
+ : {
440
+ owns: () => options.channelLock?.owns() ?? true,
441
+ ...(options.channelLock.preempted === undefined
442
+ ? {}
443
+ : { firstPollDelayMs: options.preemptWaitMs ?? PREEMPT_WAIT_MS }),
444
+ }),
445
+ onEnded: async (reason) => {
446
+ if (state.ended !== undefined) {
447
+ // The server stays up: Claude keeps its tools (which now say the session
448
+ // ended), without reply.
449
+ switchable.show(frozen());
450
+ try {
451
+ switchable.server.sendToolListChanged();
452
+ }
453
+ catch {
454
+ // A client that is gone can't be told.
455
+ }
456
+ }
457
+ await options.onSessionEnded?.(reason);
458
+ },
459
+ ...options.channelLoop,
460
+ });
461
+ void loop.start();
462
+ };
463
+ }
196
464
  return switchable.server;
197
465
  }, {
198
466
  onerror: (error) => {
199
467
  logger.error('stdio transport error', { error });
200
468
  },
201
- transport: new GatedTransport(wire, (message) => identity.beforeMessage(message)),
469
+ transport: new GatedTransport(channel ? new LegacyOnlyTransport(wire) : wire, (message) => identity.beforeMessage(message), () => {
470
+ void stopLoop();
471
+ }),
202
472
  });
473
+ return {
474
+ async close() {
475
+ await stopLoop();
476
+ await handle.close();
477
+ },
478
+ };
203
479
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kammcs/buildit-mcp",
3
- "version": "0.2.0",
3
+ "version": "0.8.0",
4
4
  "description": "MCP server for buildIt.Social: lets AI agents work with an org's projects, pages and channels through the buildIt.Social agent API, with a personal access token.",
5
5
  "license": "MIT",
6
6
  "author": "Kamm Creative Solutions",
@@ -46,6 +46,7 @@
46
46
  "inspect:docker": "npm run build && docker build -t buildit-mcp:inspect . && tsx scripts/inspect.ts --image buildit-mcp:inspect",
47
47
  "image:publish": "tsx scripts/publish-image.ts",
48
48
  "bundle": "npm run build && tsx scripts/bundle.ts",
49
+ "dev-marketplace": "tsx scripts/dev-marketplace.ts",
49
50
  "conformance": "npm run build && tsx scripts/conformance.ts",
50
51
  "generate": "tsx scripts/generate.ts",
51
52
  "privacy": "tsx scripts/privacy.ts",
@@ -73,5 +74,8 @@
73
74
  "typescript": "6.0.3",
74
75
  "typescript-eslint": "8.71.1",
75
76
  "vitest": "5.0.3"
77
+ },
78
+ "optionalDependencies": {
79
+ "@napi-rs/keyring": "2.1.1"
76
80
  }
77
81
  }