@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.
- package/CHANGELOG.md +128 -0
- package/README.md +299 -72
- package/SECURITY.md +5 -5
- package/dist/api/client.js +5 -1
- package/dist/api/generated/operations.js +717 -4
- package/dist/api/generated/schemas.js +718 -3
- package/dist/api/generated/strict.js +859 -3
- 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 +41 -5
- package/dist/tools/items-write.js +66 -15
- package/dist/tools/pages.js +81 -1
- package/dist/tools/plans.js +221 -14
- package/dist/tools/projects.js +38 -24
- package/dist/tools/reply.js +138 -0
- package/dist/tools/shared.js +34 -1
- package/dist/tools/threads.js +123 -0
- package/dist/tools/whoami.js +29 -4
- package/dist/toolsets/catalog.js +5 -2
- package/dist/toolsets/registry.js +2 -0
- package/dist/transports/stdio.js +260 -6
- package/package.json +5 -1
package/dist/tools/shared.js
CHANGED
|
@@ -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
|
|
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
|
+
}
|
package/dist/tools/whoami.js
CHANGED
|
@@ -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
|
|
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: {
|
|
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
|
|
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
|
}
|
package/dist/toolsets/catalog.js
CHANGED
|
@@ -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. */
|
package/dist/transports/stdio.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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.
|
|
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
|
}
|