@kammcs/buildit-mcp 0.1.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 (42) hide show
  1. package/CHANGELOG.md +128 -0
  2. package/README.md +299 -72
  3. package/SECURITY.md +5 -5
  4. package/dist/api/client.js +5 -1
  5. package/dist/api/generated/operations.js +717 -4
  6. package/dist/api/generated/schemas.js +718 -3
  7. package/dist/api/generated/strict.js +859 -3
  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 +41 -5
  31. package/dist/tools/items-write.js +66 -15
  32. package/dist/tools/pages.js +81 -1
  33. package/dist/tools/plans.js +221 -14
  34. package/dist/tools/projects.js +38 -24
  35. package/dist/tools/reply.js +138 -0
  36. package/dist/tools/shared.js +34 -1
  37. package/dist/tools/threads.js +123 -0
  38. package/dist/tools/whoami.js +29 -4
  39. package/dist/toolsets/catalog.js +5 -2
  40. package/dist/toolsets/registry.js +2 -0
  41. package/dist/transports/stdio.js +260 -6
  42. package/package.json +5 -1
@@ -134,6 +134,7 @@ export const ItemOutSchema = z.object({
134
134
  status_category: z.string().describe('not_started, started, done or canceled.'),
135
135
  priority: z.string(),
136
136
  assignee: PersonSchema.nullable(),
137
+ assignee_agent: z.string().nullable().describe("The assignee's agent working on it."),
137
138
  parent: z.string().nullable().describe("The parent's key."),
138
139
  labels: z.array(z.string()),
139
140
  estimate: z.number().nullable(),
@@ -157,6 +158,7 @@ export function itemOut(i) {
157
158
  status_category: i.status.category,
158
159
  priority: i.priority,
159
160
  assignee: i.assignee ? person(i.assignee) : null,
161
+ assignee_agent: i.assignee_agent ? label(i.assignee_agent.name, PERSON_NAME_CHARS) : null,
160
162
  parent: i.parent?.key ?? null,
161
163
  labels: i.labels.map((l) => label(l.name, 40)),
162
164
  estimate: i.estimate,
@@ -275,6 +277,17 @@ export const SearchItemOutSchema = ItemOutSchema.extend({
275
277
  .describe('With a text query only.'),
276
278
  description: z.string().optional().describe('With detail="full" only; wrapped, may be cut.'),
277
279
  custom: z.array(CustomOutSchema).optional().describe('With detail="full" only.'),
280
+ link_counts: z
281
+ .object({
282
+ blocks: z.number(),
283
+ blocked_by: z.number(),
284
+ relates: z.number(),
285
+ duplicates: z.number(),
286
+ duplicated_by: z.number(),
287
+ blocked_by_open: z.number(),
288
+ })
289
+ .optional(),
290
+ activity_at: z.string().optional(),
278
291
  });
279
292
  export function searchItemOut(i, maxDescription) {
280
293
  const out = itemOut(i);
@@ -296,6 +309,19 @@ export function searchItemOut(i, maxDescription) {
296
309
  }
297
310
  if (i.custom !== undefined)
298
311
  out.custom = i.custom.map(customOut);
312
+ if (i.link_counts !== undefined) {
313
+ const c = i.link_counts;
314
+ out.link_counts = {
315
+ blocks: c.blocks,
316
+ blocked_by: c.blocked_by,
317
+ relates: c.relates,
318
+ duplicates: c.duplicates,
319
+ duplicated_by: c.duplicated_by,
320
+ blocked_by_open: c.blocked_by_open,
321
+ };
322
+ }
323
+ if (i.activity_at !== undefined)
324
+ out.activity_at = i.activity_at;
299
325
  return out;
300
326
  }
301
327
  export const CommentOutSchema = z.object({
@@ -403,6 +429,13 @@ export function historyOut(h) {
403
429
  // ---------------------------------------------------------------------------
404
430
  // Text
405
431
  // ---------------------------------------------------------------------------
432
+ /** Who an item is assigned to, in lines: "@Sam", "assignee Claude · for Sam", or "unassigned". */
433
+ export function assigneeText(i) {
434
+ if (i.assignee_agent && i.assignee) {
435
+ return `assignee ${i.assignee_agent} · for ${i.assignee.name}`;
436
+ }
437
+ return i.assignee ? `@${i.assignee.name}` : 'unassigned';
438
+ }
406
439
  /** One line about an item, for lists (it carries the title, so lists are wrapped). */
407
440
  export function itemLine(i) {
408
441
  const parts = [
@@ -410,7 +443,7 @@ export function itemLine(i) {
410
443
  i.type,
411
444
  `${i.status} (${i.status_category})`,
412
445
  ...(i.priority !== 'none' ? [i.priority] : []),
413
- i.assignee ? `@${i.assignee.name}` : 'unassigned',
446
+ assigneeText(i),
414
447
  ...(i.estimate !== null ? [`est ${i.estimate}`] : []),
415
448
  ...(i.parent ? [`parent ${i.parent}`] : []),
416
449
  ...(i.sprint ? [i.sprint] : []),
@@ -0,0 +1,123 @@
1
+ /**
2
+ * list_threads: the threads this session works in (channel mode only):
3
+ * its direct-message threads and the work items' threads bound to it,
4
+ * newest activity first, at most 50 (GET /v1/session/threads).
5
+ *
6
+ * It also refreshes which item threads are active (the set the explicit
7
+ * thread rule counts), since the server's list is the one that knows.
8
+ */
9
+ import { z } from 'zod';
10
+ import { listSessionThreads } from '../channel/api.js';
11
+ import { NOT_THE_CHANNEL } from '../channel/lock.js';
12
+ import { ToolInputError } from '../errors.js';
13
+ import { defineTool } from '../toolsets/registry.js';
14
+ import { sanitizeLabel, wrapUntrusted } from '../untrusted.js';
15
+ /** Built-in statuses whose name doesn't follow from the key. */
16
+ const BUILT_IN_NAMES = { todo: 'To do', canceled: 'Cancelled' };
17
+ /** "in_review" as people read it: "In review". */
18
+ const humanize = (key) => {
19
+ const words = key.replace(/[_-]+/g, ' ').trim();
20
+ return words === '' ? '' : words.charAt(0).toUpperCase() + words.slice(1);
21
+ };
22
+ /**
23
+ * A status's name. Built-in statuses come with a key and no name (the app
24
+ * names them in the person's language), so the name follows from the key,
25
+ * else from the category.
26
+ */
27
+ export function statusName(status) {
28
+ if (status === null)
29
+ return null;
30
+ if (status.name !== null && status.name.trim() !== '')
31
+ return sanitizeLabel(status.name, 60);
32
+ const key = status.key?.trim().toLowerCase() ?? '';
33
+ const named = key === '' ? undefined : (BUILT_IN_NAMES[key] ?? humanize(key));
34
+ const name = named !== undefined && named !== '' ? named : humanize(status.category);
35
+ return name === '' ? null : sanitizeLabel(name, 60);
36
+ }
37
+ const ThreadOutSchema = z.object({
38
+ thread_id: z.string(),
39
+ conversation_id: z.string(),
40
+ kind: z.string().describe('dm: your direct message with the person; item: a work item.'),
41
+ item: z
42
+ .object({
43
+ key: z.string(),
44
+ title: z.string().describe('People-written; flattened to one defused line.'),
45
+ status: z.string().nullable(),
46
+ })
47
+ .nullable(),
48
+ open_cards: z.number(),
49
+ last_owner_message_at: z.string().nullable(),
50
+ });
51
+ /** The list_threads tool for one channel session. */
52
+ export function createListThreadsTool(state) {
53
+ return defineTool({
54
+ name: 'list_threads',
55
+ // Not in the catalog: listed only in channel mode, whatever the toolsets.
56
+ toolset: 'chat',
57
+ title: 'List the threads this session works in',
58
+ description: "Lists the threads this session works in: your direct message with the person, and the work items' threads (key, title, status, open cards, when the person last wrote). Use it to name the thread for reply, ask or an approval card (thread_id, or the item's key) while you work in several, and after a restart.",
59
+ scopes: [],
60
+ annotations: {
61
+ title: 'List the threads this session works in',
62
+ readOnlyHint: true,
63
+ destructiveHint: false,
64
+ idempotentHint: true,
65
+ openWorldHint: true,
66
+ },
67
+ inputSchema: z.object({}),
68
+ outputSchema: z.object({ threads: z.array(ThreadOutSchema) }),
69
+ async run(_args, ctx) {
70
+ if (!state.isChannel)
71
+ throw new ToolInputError(NOT_THE_CHANNEL);
72
+ const r = await listSessionThreads(ctx.api, ctx.apiOptions);
73
+ const threads = r.threads.map((t) => ({
74
+ thread_id: t.thread_id,
75
+ conversation_id: t.conversation_id,
76
+ kind: sanitizeLabel(t.kind, 20),
77
+ item: t.item === null
78
+ ? null
79
+ : {
80
+ key: sanitizeLabel(t.item.key, 40),
81
+ title: sanitizeLabel(t.item.title, 300),
82
+ status: statusName(t.item.status),
83
+ },
84
+ open_cards: t.open_cards,
85
+ last_owner_message_at: t.last_owner_message_at,
86
+ }));
87
+ // The server's list is the one that knows which item threads are bound.
88
+ state.activeItems.clear();
89
+ for (const t of r.threads) {
90
+ if (t.kind === 'item' && t.item !== null)
91
+ state.activateItem(t.item.key, t.item.id);
92
+ }
93
+ const newestDm = r.threads.find((t) => t.kind === 'dm');
94
+ if (state.lastDmThread === undefined && newestDm !== undefined) {
95
+ state.lastDmThread = newestDm.thread_id;
96
+ }
97
+ if (threads.length === 0) {
98
+ return {
99
+ structured: { threads },
100
+ text: 'This session works in no thread yet.',
101
+ };
102
+ }
103
+ const lines = threads.map((t) => {
104
+ const facts = [
105
+ `thread_id ${t.thread_id}`,
106
+ `${t.open_cards} open ${t.open_cards === 1 ? 'card' : 'cards'}`,
107
+ `the person last wrote ${t.last_owner_message_at ?? 'never'}`,
108
+ ].join(' · ');
109
+ return t.item === null
110
+ ? `your direct message · ${facts}`
111
+ : `${t.item.key} · ${t.item.status ?? 'no status'} · ${facts} — ${t.item.title}`;
112
+ });
113
+ return {
114
+ structured: { threads },
115
+ text: [
116
+ `This session works in ${threads.length} ${threads.length === 1 ? 'thread' : 'threads'}, newest activity first:`,
117
+ // The titles and statuses are people-written: the lines are one block.
118
+ wrapUntrusted(lines.join('\n'), { source: 'threads', maxChars: 40_000 }),
119
+ ].join('\n'),
120
+ };
121
+ },
122
+ });
123
+ }
@@ -2,6 +2,7 @@
2
2
  * whoami: who the token acts as, where, and with what reach.
3
3
  */
4
4
  import { z } from 'zod';
5
+ import { NOT_THE_CHANNEL } from '../channel/lock.js';
5
6
  import { OPERATIONS } from '../api/generated/operations.js';
6
7
  import { defineTool } from '../toolsets/registry.js';
7
8
  import { expandScopes } from '../toolsets/toolsets.js';
@@ -18,9 +19,20 @@ const OutputSchema = z.object({
18
19
  org: z.object({
19
20
  id: z.string(),
20
21
  name: z.string().nullable(),
22
+ test: z
23
+ .boolean()
24
+ .nullable()
25
+ .describe('A test org (true); null when the API is older than 1.2.0.'),
21
26
  }),
22
27
  token: z.object({
23
- name: z.string().nullable(),
28
+ name: z
29
+ .string()
30
+ .nullable()
31
+ .describe("The token's name; for an agent session, the session's label."),
32
+ kind: z
33
+ .string()
34
+ .nullable()
35
+ .describe('pat: a personal access token; session: an agent session (channel mode); null when the API is older.'),
24
36
  expires_at: z.string().nullable(),
25
37
  }),
26
38
  scopes: z.array(z.string()).describe('As granted; a write scope also allows reading.'),
@@ -126,9 +138,14 @@ export const whoamiTool = defineTool({
126
138
  name: nullableLabel(me.user.display_name, PERSON_NAME_CHARS),
127
139
  email: nullableLabel(me.user.email, 320),
128
140
  },
129
- org: { id: me.org.id, name: nullableLabel(me.org.name, NAME_CHARS) },
141
+ org: {
142
+ id: me.org.id,
143
+ name: nullableLabel(me.org.name, NAME_CHARS),
144
+ test: me.org.test ?? null,
145
+ },
130
146
  token: {
131
147
  name: nullableLabel(me.token.name, NAME_CHARS),
148
+ kind: me.token.kind === undefined ? null : sanitizeLabel(me.token.kind, 20),
132
149
  expires_at: me.token.expires_at,
133
150
  },
134
151
  scopes: [...me.token.scopes].sort(),
@@ -168,7 +185,9 @@ export const whoamiTool = defineTool({
168
185
  const names = [
169
186
  `User: ${s.user.name ?? s.user.id}${s.user.email ? ` (${s.user.email})` : ''}`,
170
187
  `Org: ${s.org.name ?? s.org.id}`,
171
- ...(s.token.name ? [`Token: ${s.token.name}`] : []),
188
+ ...(s.token.name
189
+ ? [`${s.token.kind === 'session' ? 'Session' : 'Token'}: ${s.token.name}`]
190
+ : []),
172
191
  ];
173
192
  if (s.projects.length > 0) {
174
193
  const shown = s.projects
@@ -180,7 +199,7 @@ export const whoamiTool = defineTool({
180
199
  names.push(`Channels the token is limited to: ${s.limits.channels.join(', ')}`);
181
200
  }
182
201
  const lines = [
183
- `Signed in to buildIt.Social with a personal access token${s.token.expires_at ? ` (expires ${s.token.expires_at})` : ''}. Who and where, as people named them:`,
202
+ `Signed in to buildIt.Social with ${s.token.kind === 'session' ? "an agent session's token (it acts as the session's owner)" : 'a personal access token'}${s.token.expires_at ? ` (expires ${s.token.expires_at})` : ''}. Who and where, as people named them:`,
184
203
  wrapLines(names, 'names'),
185
204
  `Scopes: ${s.scopes.length > 0 ? s.scopes.join(', ') : 'none'}.`,
186
205
  `Limits: ${s.limits.projects === null
@@ -191,6 +210,9 @@ export const whoamiTool = defineTool({
191
210
  ? 'no channels'
192
211
  : `only the ${s.limits.channels.length} channel(s) listed above, among those the user can see`}.`,
193
212
  ];
213
+ if (s.org.test === true) {
214
+ lines.push('This org is a test org: its data may be wiped and seeded by test tools.');
215
+ }
194
216
  if (s.cannot.length > 0) {
195
217
  lines.push(`This token can't: ${s.cannot.join('; ')}.`);
196
218
  }
@@ -211,6 +233,9 @@ export const whoamiTool = defineTool({
211
233
  if (s.server.not_served !== null && s.server.not_served.length > 0) {
212
234
  lines.push(`This buildIt.Social server (API ${s.server.api_version ?? 'unknown'}) doesn't serve these operations yet, so calls that need them are refused until it is updated: ${s.server.not_served.join(', ')}.`);
213
235
  }
236
+ if (ctx.channel !== undefined && !ctx.channel.isChannel) {
237
+ lines.push(NOT_THE_CHANNEL);
238
+ }
214
239
  if (updateRequired) {
215
240
  lines.push(`This buildit-mcp (${SERVER_VERSION}) is older than the API supports (${minVersion} or newer). Ask the person to update it.`);
216
241
  }
@@ -5,11 +5,11 @@
5
5
  */
6
6
  import { PROMPTS } from '../prompts.js';
7
7
  import { RESOURCES } from '../resources.js';
8
- import { listChannelsTool, readChannelTool, readThreadTool } from '../tools/chat.js';
8
+ import { listChannelsTool, listEmojiTool, listPinsTool, readChannelTool, readThreadTool, } from '../tools/chat.js';
9
9
  import { addCommentTool, listCommentsTool } from '../tools/comments.js';
10
10
  import { getItemTool, searchItemsTool } from '../tools/items-read.js';
11
11
  import { assignItemTool, createItemTool, linkItemsTool, rankItemTool, transitionItemTool, unlinkItemsTool, updateItemTool, } from '../tools/items-write.js';
12
- import { createPageTool, getPageTool, listPagesTool, updatePageTool } from '../tools/pages.js';
12
+ import { createPageTool, getPageTool, listPagesTool, searchPagesTool, updatePageTool, } from '../tools/pages.js';
13
13
  import { listReleasesTool, listSprintsTool, planReleaseTool, planSprintTool, writeReleaseNotesTool, } from '../tools/planning.js';
14
14
  import { applyPlanTool, getWorkflowTool, listWorkTypesTool, proposeArchiveStatusTool, proposeBulkUpdateTool, proposeDeleteItemTool, proposeFieldChangeTool, proposeLabelChangeTool, proposeMoveItemTool, proposeWorkflowChangeTool, proposeWorkTypeChangeTool, } from '../tools/plans.js';
15
15
  import { describeProjectTool, findUsersTool, listProjectsTool } from '../tools/projects.js';
@@ -41,6 +41,7 @@ export const CATALOG = [
41
41
  writeReleaseNotesTool,
42
42
  // pages
43
43
  listPagesTool,
44
+ searchPagesTool,
44
45
  getPageTool,
45
46
  createPageTool,
46
47
  updatePageTool,
@@ -48,6 +49,8 @@ export const CATALOG = [
48
49
  listChannelsTool,
49
50
  readChannelTool,
50
51
  readThreadTool,
52
+ listPinsTool,
53
+ listEmojiTool,
51
54
  // admin
52
55
  createChannelTool,
53
56
  createProjectTool,
@@ -13,6 +13,8 @@ export function createToolContext(init) {
13
13
  ...(init.signal ? { signal: init.signal } : {}),
14
14
  apiOptions,
15
15
  call: (id, args = {}) => callOperation(init.api, id, args, apiOptions),
16
+ ...(init.channel ? { channel: init.channel } : {}),
17
+ ...(init.progress ? { progress: init.progress } : {}),
16
18
  };
17
19
  }
18
20
  /** Identity helper that keeps the argument and output types tied to the schemas. */
@@ -1,10 +1,28 @@
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';
6
14
  import { CATALOG } from '../toolsets/catalog.js';
7
15
  import { compareVersions, SERVER_VERSION } from '../version.js';
16
+ /**
17
+ * The plugin's AskUserQuestion and PermissionRequest hooks' timeout, in
18
+ * seconds (plugin/hooks/hooks.json). A card's wait ends a minute before it,
19
+ * so the answer arrives in time.
20
+ */
21
+ export const HOOK_TIMEOUT_S = 3600;
22
+ /** How long a hook's card waits: the configured minutes, a minute under the hook's timeout at most. */
23
+ export function hookWaitSeconds(minutes) {
24
+ return Math.min(minutes * 60, HOOK_TIMEOUT_S - 60);
25
+ }
8
26
  /** A known identity is read again at most this often. */
9
27
  export const IDENTITY_REFRESH_MS = 60_000;
10
28
  /** Methods whose answer depends on the token's scopes: they wait for a due refresh. */
@@ -111,13 +129,17 @@ export class StdioIdentity {
111
129
  class GatedTransport {
112
130
  inner;
113
131
  before;
132
+ closed;
114
133
  onclose;
115
134
  onerror;
116
135
  onmessage;
117
136
  queue = Promise.resolve();
118
- constructor(inner, before) {
137
+ constructor(inner, before,
138
+ /** Told when the connection closes (stdin ended, or close()). */
139
+ closed = () => undefined) {
119
140
  this.inner = inner;
120
141
  this.before = before;
142
+ this.closed = closed;
121
143
  }
122
144
  start() {
123
145
  this.inner.onmessage = (message, extra) => {
@@ -129,6 +151,71 @@ class GatedTransport {
129
151
  });
130
152
  };
131
153
  this.inner.onerror = (error) => this.onerror?.(error);
154
+ this.inner.onclose = () => {
155
+ this.closed();
156
+ this.onclose?.();
157
+ };
158
+ return this.inner.start();
159
+ }
160
+ send(message, options) {
161
+ return this.inner.send(message, options);
162
+ }
163
+ close() {
164
+ return this.inner.close();
165
+ }
166
+ }
167
+ /** Whether a message claims a protocol revision in its _meta (the 2026-07-28 way of opening). */
168
+ function claimsRevision(message) {
169
+ if (!isJSONRPCRequest(message) && !isJSONRPCNotification(message))
170
+ return false;
171
+ const params = message.params;
172
+ return params?._meta?.[PROTOCOL_VERSION_META_KEY] !== undefined;
173
+ }
174
+ /**
175
+ * Keeps a connection on the 2025 handshake (channel mode): until
176
+ * `initialize`, a request that claims a revision is answered with the
177
+ * unsupported-version error naming only the 2025-era revisions, which makes
178
+ * a client fall back to `initialize`; such a notification is dropped.
179
+ */
180
+ export class LegacyOnlyTransport {
181
+ inner;
182
+ onclose;
183
+ onerror;
184
+ onmessage;
185
+ initialized = false;
186
+ constructor(inner) {
187
+ this.inner = inner;
188
+ }
189
+ start() {
190
+ this.inner.onmessage = (message, extra) => {
191
+ if (!this.initialized && claimsRevision(message)) {
192
+ if (isJSONRPCRequest(message)) {
193
+ const meta = message.params._meta ?? {};
194
+ const requested = meta[PROTOCOL_VERSION_META_KEY];
195
+ void this.inner
196
+ .send({
197
+ jsonrpc: '2.0',
198
+ id: message.id,
199
+ error: {
200
+ code: ProtocolErrorCode.UnsupportedProtocolVersion,
201
+ message: 'Unsupported protocol version: this channel server speaks the 2025 handshake.',
202
+ data: {
203
+ supported: [...SUPPORTED_PROTOCOL_VERSIONS],
204
+ requested: typeof requested === 'string' ? requested : 'unknown',
205
+ },
206
+ },
207
+ })
208
+ .catch((error) => {
209
+ this.onerror?.(error instanceof Error ? error : new Error(String(error)));
210
+ });
211
+ }
212
+ return;
213
+ }
214
+ if (isJSONRPCRequest(message) && message.method === 'initialize')
215
+ this.initialized = true;
216
+ this.onmessage?.(message, extra);
217
+ };
218
+ this.inner.onerror = (error) => this.onerror?.(error);
132
219
  this.inner.onclose = () => this.onclose?.();
133
220
  return this.inner.start();
134
221
  }
@@ -139,6 +226,56 @@ class GatedTransport {
139
226
  return this.inner.close();
140
227
  }
141
228
  }
229
+ /** The tools that post in a thread and stop its "is writing" themselves. */
230
+ const POSTING_TOOLS = new Set(['reply', 'ask', 'turn_ended', 'list_threads']);
231
+ /**
232
+ * A tool call is activity for "is writing": for the thread it names (its
233
+ * thread_id or card_thread, or an item's key that the session works on),
234
+ * else for the only working thread. A call that works on an active item's
235
+ * thread (a worker reading or changing its item) shows "is writing" there
236
+ * again; the posting tools stop it themselves.
237
+ */
238
+ function noteActivity(state, tool, args) {
239
+ const typing = state.typing;
240
+ if (typing === undefined)
241
+ return;
242
+ // The plugin's approval hooks: tool_seen counts the activity itself.
243
+ if (APPROVAL_HOOK_TOOLS.has(tool))
244
+ return;
245
+ const a = (args !== null && typeof args === 'object' ? args : {});
246
+ const str = (v) => typeof v === 'string' && v !== '' ? v : undefined;
247
+ const ref = str(a.thread_id) ?? str(a.card_thread) ?? str(a.item);
248
+ const key = ref?.startsWith('#') ? ref.slice(1) : ref;
249
+ const thread = key === undefined
250
+ ? undefined
251
+ : state.activeItems.has(key)
252
+ ? key
253
+ : state.items.get(key.toUpperCase());
254
+ if (thread === undefined || !state.activeItems.has(thread)) {
255
+ typing.activity(key !== undefined && thread === undefined ? key : undefined);
256
+ return;
257
+ }
258
+ if (POSTING_TOOLS.has(tool))
259
+ typing.activity(thread);
260
+ else
261
+ typing.work(thread);
262
+ }
263
+ /**
264
+ * The tools only channel mode has (reply, ask, list_threads, and the
265
+ * plugin's hooks: turn_ended, approve and the hooks that end approvals),
266
+ * under the operator's read-only and exclude rules.
267
+ */
268
+ function channelTools(policy, state, ask, approve) {
269
+ return [
270
+ createReplyTool(state),
271
+ createAskTool(state, ask),
272
+ createListThreadsTool(state),
273
+ createTurnEndedTool(state),
274
+ createApproveTool(state, approve),
275
+ ...createApprovalHookTools(state),
276
+ ].filter((tool) => (!policy.readOnly || tool.annotations.readOnlyHint || READ_ONLY_SAFE_HOOKS.has(tool.name)) &&
277
+ !policy.excludeTools.includes(tool.name));
278
+ }
142
279
  export function startStdio(config, logger, options = {}) {
143
280
  if (config.token === undefined)
144
281
  throw new Error('stdio mode needs a token');
@@ -159,7 +296,56 @@ export function startStdio(config, logger, options = {}) {
159
296
  invalidations.listener();
160
297
  },
161
298
  });
162
- const identity = new StdioIdentity(() => resolveListedTools(identityFromApi(api), catalog, policy, logger), logger, options.now);
299
+ const channel = config.channel !== undefined;
300
+ const state = new ChannelState();
301
+ state.isChannel = options.channelOwner ?? true;
302
+ state.folder = config.channel?.folder;
303
+ if (channel && state.isChannel) {
304
+ state.typing = new TypingIndicator({
305
+ api,
306
+ logger: logger.child({ component: 'typing' }),
307
+ ...options.typing,
308
+ });
309
+ state.approvals = new ToolApprovals({
310
+ logger: logger.child({ component: 'approvals' }),
311
+ journal: options.approvalJournal,
312
+ });
313
+ }
314
+ const extraTools = channel
315
+ ? channelTools(policy, state, {
316
+ questionsInApp: config.ask.questionsInApp,
317
+ hookWaitS: hookWaitSeconds(config.ask.timeoutMinutes),
318
+ ...options.ask,
319
+ }, {
320
+ approvalsInApp: config.approvals.inApp,
321
+ hookWaitS: hookWaitSeconds(config.approvals.timeoutMinutes),
322
+ ...options.approve,
323
+ })
324
+ : [];
325
+ // Once a channel's session has ended, the listing stays as it last was
326
+ // (without reply), so Claude keeps its tools, which then say why.
327
+ let lastKnown;
328
+ const isEnded = () => state.ended !== undefined;
329
+ const frozen = () => {
330
+ const base = lastKnown ?? {
331
+ ...listedByPolicy(catalog, policy),
332
+ identity: 'unverified',
333
+ };
334
+ return {
335
+ ...base,
336
+ tools: base.tools.filter((t) => !extraTools.includes(t) || t.runsAfterSessionEnd === true),
337
+ };
338
+ };
339
+ const identity = new StdioIdentity(async () => {
340
+ if (isEnded())
341
+ return frozen();
342
+ const listed = await resolveListedTools(identityFromApi(api), catalog, policy, logger);
343
+ const shown = extraTools.length > 0 ? { ...listed, tools: [...listed.tools, ...extraTools] } : listed;
344
+ if (shown.identity === 'known')
345
+ lastKnown = shown;
346
+ // The session may have ended while the identity was read.
347
+ return isEnded() ? frozen() : shown;
348
+ }, logger, options.now);
163
349
  invalidations.listener = () => {
164
350
  identity.invalidate();
165
351
  };
@@ -182,22 +368,90 @@ export function startStdio(config, logger, options = {}) {
182
368
  const wire = options.stdin && options.stdout
183
369
  ? new StdioServerTransport(options.stdin, options.stdout)
184
370
  : new StdioServerTransport();
185
- return serveStdio(async () => {
371
+ let loop;
372
+ const stopLoop = () => loop?.stop() ?? Promise.resolve();
373
+ const handle = serveStdio(async () => {
186
374
  const listed = await identity.refresh();
187
375
  const switchable = createSwitchableMcpServer({
188
- tools: allowed.tools,
376
+ tools: [...allowed.tools, ...extraTools],
189
377
  resources: allowed.resources,
190
378
  prompts: allowed.prompts,
191
379
  api,
192
380
  logger,
381
+ channel,
382
+ ...(channel
383
+ ? {
384
+ channelState: state,
385
+ ...(config.channel?.fromPlugin === true
386
+ ? { lead: { maxParallelItems: config.ask.maxParallelItems } }
387
+ : {}),
388
+ onToolCall: (tool, args) => {
389
+ noteActivity(state, tool, args);
390
+ },
391
+ sessionEnded: () => state.ended,
392
+ onSessionOver: (code) => {
393
+ void loop?.end(code);
394
+ },
395
+ }
396
+ : {}),
193
397
  ...(listed.identity === 'known' ? {} : { toolsListTtlMs: DEGRADED_LIST_TTL_MS }),
194
398
  });
195
399
  identity.attach(switchable);
400
+ if (channel) {
401
+ const mcp = switchable.server.server;
402
+ // Notifications sent before the client has initialized would be lost.
403
+ mcp.oninitialized = () => {
404
+ if (loop !== undefined)
405
+ return;
406
+ if (!state.isChannel) {
407
+ 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)');
408
+ return;
409
+ }
410
+ loop = new ChannelLoop({
411
+ api,
412
+ logger: logger.child({ component: 'channel' }),
413
+ state,
414
+ notify: (params) => mcp.notification({ method: CHANNEL_NOTIFICATION, params }),
415
+ ...(options.channelLock === undefined
416
+ ? {}
417
+ : {
418
+ owns: () => options.channelLock?.owns() ?? true,
419
+ ...(options.channelLock.preempted === undefined
420
+ ? {}
421
+ : { firstPollDelayMs: options.preemptWaitMs ?? PREEMPT_WAIT_MS }),
422
+ }),
423
+ onEnded: async (reason) => {
424
+ if (state.ended !== undefined) {
425
+ // The server stays up: Claude keeps its tools (which now say the session
426
+ // ended), without reply.
427
+ switchable.show(frozen());
428
+ try {
429
+ switchable.server.sendToolListChanged();
430
+ }
431
+ catch {
432
+ // A client that is gone can't be told.
433
+ }
434
+ }
435
+ await options.onSessionEnded?.(reason);
436
+ },
437
+ ...options.channelLoop,
438
+ });
439
+ void loop.start();
440
+ };
441
+ }
196
442
  return switchable.server;
197
443
  }, {
198
444
  onerror: (error) => {
199
445
  logger.error('stdio transport error', { error });
200
446
  },
201
- transport: new GatedTransport(wire, (message) => identity.beforeMessage(message)),
447
+ transport: new GatedTransport(channel ? new LegacyOnlyTransport(wire) : wire, (message) => identity.beforeMessage(message), () => {
448
+ void stopLoop();
449
+ }),
202
450
  });
451
+ return {
452
+ async close() {
453
+ await stopLoop();
454
+ await handle.close();
455
+ },
456
+ };
203
457
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kammcs/buildit-mcp",
3
- "version": "0.1.0",
3
+ "version": "0.7.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
  }