@kammcs/buildit-mcp 0.0.0-stage → 0.2.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 +84 -0
- package/LICENSE +21 -0
- package/README.md +325 -2
- package/SECURITY.md +48 -0
- package/dist/api/client.js +382 -0
- package/dist/api/generated/operations.js +1615 -0
- package/dist/api/generated/schemas.js +1413 -0
- package/dist/api/generated/strict.js +1681 -0
- package/dist/api/operations.js +43 -0
- package/dist/config.js +280 -0
- package/dist/errors.js +347 -0
- package/dist/identity.js +124 -0
- package/dist/index.js +73 -0
- package/dist/log.js +90 -0
- package/dist/prompts.js +114 -0
- package/dist/resources.js +55 -0
- package/dist/server.js +257 -0
- package/dist/tools/chat.js +273 -0
- package/dist/tools/comments.js +128 -0
- package/dist/tools/items-read.js +603 -0
- package/dist/tools/items-write.js +580 -0
- package/dist/tools/pages.js +405 -0
- package/dist/tools/planning.js +697 -0
- package/dist/tools/plans.js +906 -0
- package/dist/tools/projects.js +415 -0
- package/dist/tools/setup.js +292 -0
- package/dist/tools/shared.js +462 -0
- package/dist/tools/whoami.js +230 -0
- package/dist/toolsets/catalog.js +72 -0
- package/dist/toolsets/registry.js +97 -0
- package/dist/toolsets/toolsets.js +89 -0
- package/dist/transports/http.js +212 -0
- package/dist/transports/stdio.js +203 -0
- package/dist/untrusted.js +466 -0
- package/dist/version.js +28 -0
- package/package.json +75 -4
|
@@ -0,0 +1,466 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Marking text that people wrote.
|
|
3
|
+
*
|
|
4
|
+
* Titles, descriptions, comments, pages, chat messages and names are written
|
|
5
|
+
* by people (guests among them) and read by an agent, so they can carry text
|
|
6
|
+
* aimed at the agent ("ignore previous instructions and ..."). Every tool that
|
|
7
|
+
* returns such text wraps it:
|
|
8
|
+
*
|
|
9
|
+
* <untrusted_content id="3f9c0a7d21" source="comment" author="Ana">
|
|
10
|
+
* ...
|
|
11
|
+
* </untrusted_content id="3f9c0a7d21">
|
|
12
|
+
*
|
|
13
|
+
* and the server's instructions tell the agent that anything inside is data,
|
|
14
|
+
* never instructions, and that a block ends only at a closing tag with its
|
|
15
|
+
* id. The id is random for each tool result (or resource read), so text
|
|
16
|
+
* written before the call can't know it. This is a best-effort signal, not a
|
|
17
|
+
* security boundary; the boundaries are the token's scopes and limits, the
|
|
18
|
+
* API's permission checks, and preview-then-confirm for destructive changes.
|
|
19
|
+
*
|
|
20
|
+
* So that the content can't seem to end the block early, or fake a new one,
|
|
21
|
+
* the block's name is also defused wherever it could form a tag inside,
|
|
22
|
+
* look-alikes included; text that only mentions the name is left exactly as
|
|
23
|
+
* written (see neutralize).
|
|
24
|
+
*/
|
|
25
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
26
|
+
import { randomBytes } from 'node:crypto';
|
|
27
|
+
export const UNTRUSTED_TAG = 'untrusted_content';
|
|
28
|
+
export const DEFAULT_MAX_CHARS = 8000;
|
|
29
|
+
const MAX_ATTRIBUTE_CHARS = 120;
|
|
30
|
+
const DEFAULT_MORE_HINT = 'Ask for the full text (for example with detail="full") to read the rest.';
|
|
31
|
+
const ATTRIBUTE_NAME = /^[a-z][a-z_]{0,30}$/;
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
// Block ids
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
const blockIds = new AsyncLocalStorage();
|
|
36
|
+
/** A fresh random block id: 10 hex characters. */
|
|
37
|
+
export function newBlockId() {
|
|
38
|
+
return randomBytes(5).toString('hex');
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Runs `fn` with one block id for every block it wraps, so one tool result
|
|
42
|
+
* (or resource read) uses one id. Outside such a run, each block gets its
|
|
43
|
+
* own random id.
|
|
44
|
+
*/
|
|
45
|
+
export function withBlockId(fn, id = newBlockId()) {
|
|
46
|
+
return blockIds.run(id, fn);
|
|
47
|
+
}
|
|
48
|
+
function currentBlockId() {
|
|
49
|
+
return blockIds.getStore() ?? newBlockId();
|
|
50
|
+
}
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
// Defusing the block's name
|
|
53
|
+
// ---------------------------------------------------------------------------
|
|
54
|
+
/**
|
|
55
|
+
* Cyrillic, Greek and other letters that look like the Latin letters of
|
|
56
|
+
* "untrusted" and "content" (lower case; text is lower-cased first).
|
|
57
|
+
*/
|
|
58
|
+
const LOOK_ALIKES = {
|
|
59
|
+
'\u{3C5}': 'u', // Greek upsilon
|
|
60
|
+
'\u{57D}': 'u', // Armenian seh
|
|
61
|
+
'\u{1D1C}': 'u', // small capital U
|
|
62
|
+
'\u{3BD}': 'n', // Greek nu (from upper-case N)
|
|
63
|
+
'\u{3B7}': 'n', // Greek eta
|
|
64
|
+
'\u{43F}': 'n', // Cyrillic pe
|
|
65
|
+
'\u{274}': 'n', // small capital N
|
|
66
|
+
'\u{3C4}': 't', // Greek tau
|
|
67
|
+
'\u{442}': 't', // Cyrillic te
|
|
68
|
+
'\u{1D1B}': 't', // small capital T
|
|
69
|
+
'\u{433}': 'r', // Cyrillic ghe
|
|
70
|
+
'\u{280}': 'r', // small capital R
|
|
71
|
+
'\u{455}': 's', // Cyrillic dze
|
|
72
|
+
'\u{A731}': 's', // small capital S
|
|
73
|
+
'\u{435}': 'e', // Cyrillic ie
|
|
74
|
+
'\u{3B5}': 'e', // Greek epsilon
|
|
75
|
+
'\u{1D07}': 'e', // small capital E
|
|
76
|
+
'\u{501}': 'd', // Cyrillic komi de
|
|
77
|
+
'\u{1D05}': 'd', // small capital D
|
|
78
|
+
'\u{441}': 'c', // Cyrillic es
|
|
79
|
+
'\u{3F2}': 'c', // Greek lunate sigma
|
|
80
|
+
'\u{1D04}': 'c', // small capital C
|
|
81
|
+
'\u{43E}': 'o', // Cyrillic o
|
|
82
|
+
'\u{3BF}': 'o', // Greek omicron
|
|
83
|
+
'\u{3C3}': 'o', // Greek sigma
|
|
84
|
+
'\u{1D0F}': 'o', // small capital O
|
|
85
|
+
};
|
|
86
|
+
/**
|
|
87
|
+
* Characters that render as nothing or only change the one before them,
|
|
88
|
+
* dropped from the shadow copy: default ignorable code points (invisible
|
|
89
|
+
* formatting characters, tag characters U+E0000 to U+E007F, and the
|
|
90
|
+
* variation selectors, including the supplementary U+E0100 to U+E01EF and
|
|
91
|
+
* the Mongolian U+180B to U+180F) and combining marks.
|
|
92
|
+
*/
|
|
93
|
+
const STRIPPED = /[\p{Default_Ignorable_Code_Point}\p{M}]/u;
|
|
94
|
+
/** The name in the shadow copy: the two words with any punctuation, spaces or controls between. */
|
|
95
|
+
const NAME_IN_SHADOW = /untrusted[\p{P}\p{Z}\p{Cc}\s]*content/gu;
|
|
96
|
+
/** What may sit between a bracket and the name: spaces, controls and the stripped characters. */
|
|
97
|
+
const GAP = /[\s\p{Cc}\p{Default_Ignorable_Code_Point}\p{M}]/u;
|
|
98
|
+
/** Slashes and their look-alikes. */
|
|
99
|
+
const SLASH = /[/\\\u{2044}\u{2215}\u{29F8}\u{2571}\u{FF0F}\u{FF3C}\u{27CB}]/u;
|
|
100
|
+
/**
|
|
101
|
+
* Opening angle brackets and their look-alikes. Compatibility forms (<, ﹤,
|
|
102
|
+
* 〈, ...) are found through their shadow (NFKD, marks dropped).
|
|
103
|
+
*/
|
|
104
|
+
const OPENERS = new Set([
|
|
105
|
+
'<',
|
|
106
|
+
'\u{2C2}', // ˂ modifier letter left arrowhead
|
|
107
|
+
'\u{2039}', // ‹ single left-pointing angle quotation mark
|
|
108
|
+
'\u{3008}', // 〈 CJK left angle bracket (and U+2329 through NFKD)
|
|
109
|
+
'\u{27E8}', // ⟨ mathematical left angle bracket
|
|
110
|
+
'\u{27EA}', // ⟪ mathematical left double angle bracket
|
|
111
|
+
'\u{276C}', // ❬ medium left-pointing angle bracket ornament
|
|
112
|
+
'\u{276E}', // ❮ heavy left-pointing angle quotation mark ornament
|
|
113
|
+
'\u{2770}', // ❰ heavy left-pointing angle bracket ornament
|
|
114
|
+
'\u{29FC}', // ⧼ left-pointing curved angle bracket
|
|
115
|
+
'\u{2991}', // ⦑ left angle bracket with dot
|
|
116
|
+
'\u{2993}', // ⦓ left arc less-than bracket
|
|
117
|
+
'\u{227A}', // ≺ precedes
|
|
118
|
+
'\u{1438}', // ᐸ Canadian syllabics pa
|
|
119
|
+
]);
|
|
120
|
+
/** Closing angle brackets and their look-alikes, as OPENERS. */
|
|
121
|
+
const CLOSERS = new Set([
|
|
122
|
+
'>',
|
|
123
|
+
'\u{2C3}', // ˃
|
|
124
|
+
'\u{203A}', // ›
|
|
125
|
+
'\u{3009}', // 〉 (and U+232A through NFKD)
|
|
126
|
+
'\u{27E9}', // ⟩
|
|
127
|
+
'\u{27EB}', // ⟫
|
|
128
|
+
'\u{276D}', // ❭
|
|
129
|
+
'\u{276F}', // ❯
|
|
130
|
+
'\u{2771}', // ❱
|
|
131
|
+
'\u{29FD}', // ⧽
|
|
132
|
+
'\u{2992}', // ⦒
|
|
133
|
+
'\u{2994}', // ⦔
|
|
134
|
+
'\u{227B}', // ≻ succeeds
|
|
135
|
+
'\u{1433}', // ᐳ Canadian syllabics po
|
|
136
|
+
]);
|
|
137
|
+
/** `<` as an HTML entity, ending at the end of the string. */
|
|
138
|
+
const OPEN_ENTITY = /&(?:lt|#0*60|#x0*3c);$/i;
|
|
139
|
+
/** `>` as an HTML entity, at the start of the string. */
|
|
140
|
+
const CLOSE_ENTITY = /^&(?:gt|#0*62|#x0*3e);/i;
|
|
141
|
+
/** How far back or ahead an entity can reach, in UTF-16 units. */
|
|
142
|
+
const ENTITY_REACH = 16;
|
|
143
|
+
/** What the name can be glued to as the end of a word, path, URL or anchor (not a tag). */
|
|
144
|
+
const WORDLIKE = /[\p{L}\p{N}#/.\-_~%+=?&@:]/u;
|
|
145
|
+
/** Characters an attribute name or an unquoted value can't contain (besides gaps and brackets). */
|
|
146
|
+
const NOT_IN_ATTRIBUTE = /["'`=<>]/;
|
|
147
|
+
// eslint-disable-next-line no-control-regex -- ASCII is the fast path
|
|
148
|
+
const ASCII_ONLY = /^[\u0000-\u007F]*$/;
|
|
149
|
+
/** What one code point contributes to the shadow copy. */
|
|
150
|
+
function shadowOf(ch, cache) {
|
|
151
|
+
const cached = cache.get(ch);
|
|
152
|
+
if (cached !== undefined)
|
|
153
|
+
return cached;
|
|
154
|
+
let out = '';
|
|
155
|
+
if (!STRIPPED.test(ch)) {
|
|
156
|
+
for (const c of ch.normalize('NFKD').toLowerCase()) {
|
|
157
|
+
if (STRIPPED.test(c))
|
|
158
|
+
continue;
|
|
159
|
+
out += LOOK_ALIKES[c] ?? c;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
cache.set(ch, out);
|
|
163
|
+
return out;
|
|
164
|
+
}
|
|
165
|
+
/** Where the name appears in `text`, as [start, end) spans of `text`, in order. */
|
|
166
|
+
function nameSpans(text, cache) {
|
|
167
|
+
const spans = [];
|
|
168
|
+
if (ASCII_ONLY.test(text)) {
|
|
169
|
+
// One shadow character per character: the indexes are the same.
|
|
170
|
+
for (const m of text.toLowerCase().matchAll(NAME_IN_SHADOW)) {
|
|
171
|
+
spans.push([m.index, m.index + m[0].length]);
|
|
172
|
+
}
|
|
173
|
+
return spans;
|
|
174
|
+
}
|
|
175
|
+
// The shadow copy: NFKD, lower case, without invisible characters and
|
|
176
|
+
// marks, look-alikes folded to Latin. Each of its UTF-16 units remembers
|
|
177
|
+
// the code point of `text` it came from (start and length).
|
|
178
|
+
let shadow = '';
|
|
179
|
+
const from = [];
|
|
180
|
+
const size = [];
|
|
181
|
+
for (let i = 0; i < text.length;) {
|
|
182
|
+
const cp = text.codePointAt(i) ?? 0;
|
|
183
|
+
const len = cp > 0xffff ? 2 : 1;
|
|
184
|
+
const piece = cp < 0x80 ? text.charAt(i).toLowerCase() : shadowOf(text.slice(i, i + len), cache);
|
|
185
|
+
shadow += piece;
|
|
186
|
+
for (let k = 0; k < piece.length; k++) {
|
|
187
|
+
from.push(i);
|
|
188
|
+
size.push(len);
|
|
189
|
+
}
|
|
190
|
+
i += len;
|
|
191
|
+
}
|
|
192
|
+
for (const m of shadow.matchAll(NAME_IN_SHADOW)) {
|
|
193
|
+
const last = m.index + m[0].length - 1;
|
|
194
|
+
spans.push([from[m.index] ?? 0, (from[last] ?? 0) + (size[last] ?? 1)]);
|
|
195
|
+
}
|
|
196
|
+
return spans;
|
|
197
|
+
}
|
|
198
|
+
/** The code point that ends just before `index`, and its length in UTF-16 units. */
|
|
199
|
+
function before(text, index) {
|
|
200
|
+
if (index <= 0)
|
|
201
|
+
return undefined;
|
|
202
|
+
const low = text.charCodeAt(index - 1);
|
|
203
|
+
if (index >= 2 && low >= 0xdc00 && low <= 0xdfff) {
|
|
204
|
+
const high = text.charCodeAt(index - 2);
|
|
205
|
+
if (high >= 0xd800 && high <= 0xdbff)
|
|
206
|
+
return { ch: text.slice(index - 2, index), len: 2 };
|
|
207
|
+
}
|
|
208
|
+
return { ch: text.charAt(index - 1), len: 1 };
|
|
209
|
+
}
|
|
210
|
+
/** The code point that starts at `index`. */
|
|
211
|
+
function at(text, index) {
|
|
212
|
+
const cp = text.codePointAt(index);
|
|
213
|
+
return cp === undefined ? '' : String.fromCodePoint(cp);
|
|
214
|
+
}
|
|
215
|
+
function isBracket(ch, set, cache) {
|
|
216
|
+
return set.has(ch) || (ch.charCodeAt(0) > 0x7f && set.has(shadowOf(ch, cache)));
|
|
217
|
+
}
|
|
218
|
+
/** Skips gap characters from `index`; the index after them. */
|
|
219
|
+
function skipGap(text, index) {
|
|
220
|
+
let i = index;
|
|
221
|
+
while (i < text.length) {
|
|
222
|
+
const ch = at(text, i);
|
|
223
|
+
if (!GAP.test(ch))
|
|
224
|
+
break;
|
|
225
|
+
i += ch.length;
|
|
226
|
+
}
|
|
227
|
+
return i;
|
|
228
|
+
}
|
|
229
|
+
/** Skips a run of characters that may make up an attribute name or an unquoted value. */
|
|
230
|
+
function skipAttributeRun(text, index, cache, slashes) {
|
|
231
|
+
let i = index;
|
|
232
|
+
while (i < text.length) {
|
|
233
|
+
const ch = at(text, i);
|
|
234
|
+
if (GAP.test(ch) || NOT_IN_ATTRIBUTE.test(ch))
|
|
235
|
+
break;
|
|
236
|
+
if (!slashes && SLASH.test(ch))
|
|
237
|
+
break;
|
|
238
|
+
if (isBracket(ch, CLOSERS, cache) || isBracket(ch, OPENERS, cache))
|
|
239
|
+
break;
|
|
240
|
+
i += ch.length;
|
|
241
|
+
}
|
|
242
|
+
return i;
|
|
243
|
+
}
|
|
244
|
+
/**
|
|
245
|
+
* Whether the name that ends at `end` is followed by the rest of a tag: a
|
|
246
|
+
* closing bracket (or `>`), right after it or after attributes
|
|
247
|
+
* (`key="value"`, `key='value'` or `key=value`, each after a space), with an
|
|
248
|
+
* optional slash before the bracket. A space and a bracket with no attribute
|
|
249
|
+
* between, as in "Settings > untrusted_content > Ids", is prose. `memo`
|
|
250
|
+
* keeps the answer for each state the scan passes (a position, and whether
|
|
251
|
+
* an attribute came before it), so that scans from many names share their
|
|
252
|
+
* work and the whole is linear.
|
|
253
|
+
*/
|
|
254
|
+
function closesTag(text, end, cache, memo) {
|
|
255
|
+
const isCloserAt = (i) => i < text.length &&
|
|
256
|
+
(isBracket(at(text, i), CLOSERS, cache) || CLOSE_ENTITY.test(text.slice(i, i + ENTITY_REACH)));
|
|
257
|
+
const path = [];
|
|
258
|
+
let answer = false;
|
|
259
|
+
let i = end;
|
|
260
|
+
let attributes = false;
|
|
261
|
+
for (;;) {
|
|
262
|
+
const state = 2 * i + (attributes ? 1 : 0);
|
|
263
|
+
const known = memo.get(state);
|
|
264
|
+
if (known !== undefined) {
|
|
265
|
+
answer = known;
|
|
266
|
+
break;
|
|
267
|
+
}
|
|
268
|
+
path.push(state);
|
|
269
|
+
let k = skipGap(text, i);
|
|
270
|
+
if (k >= text.length)
|
|
271
|
+
break;
|
|
272
|
+
const spaced = /\s/.test(text.slice(i, k));
|
|
273
|
+
const ch = at(text, k);
|
|
274
|
+
if (attributes || !spaced) {
|
|
275
|
+
if (isCloserAt(k)) {
|
|
276
|
+
answer = true;
|
|
277
|
+
break;
|
|
278
|
+
}
|
|
279
|
+
if (SLASH.test(ch)) {
|
|
280
|
+
answer = isCloserAt(skipGap(text, k + ch.length));
|
|
281
|
+
break;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
// An attribute, after at least one space.
|
|
285
|
+
if (k === i)
|
|
286
|
+
break;
|
|
287
|
+
const nameEnd = skipAttributeRun(text, k, cache, false);
|
|
288
|
+
if (nameEnd === k)
|
|
289
|
+
break;
|
|
290
|
+
k = skipGap(text, nameEnd);
|
|
291
|
+
if (text.charAt(k) !== '=')
|
|
292
|
+
break;
|
|
293
|
+
k = skipGap(text, k + 1);
|
|
294
|
+
const quote = text.charAt(k);
|
|
295
|
+
if (quote === '"' || quote === "'") {
|
|
296
|
+
const close = text.indexOf(quote, k + 1);
|
|
297
|
+
if (close < 0)
|
|
298
|
+
break;
|
|
299
|
+
i = close + 1;
|
|
300
|
+
}
|
|
301
|
+
else {
|
|
302
|
+
const valueEnd = skipAttributeRun(text, k, cache, true);
|
|
303
|
+
if (valueEnd === k)
|
|
304
|
+
break;
|
|
305
|
+
i = valueEnd;
|
|
306
|
+
}
|
|
307
|
+
attributes = true;
|
|
308
|
+
}
|
|
309
|
+
for (const state of path)
|
|
310
|
+
memo.set(state, answer);
|
|
311
|
+
return answer;
|
|
312
|
+
}
|
|
313
|
+
/**
|
|
314
|
+
* Defuses every place in `text` where the block's name could form a tag that
|
|
315
|
+
* opens or closes an untrusted_content block, and leaves all other text as
|
|
316
|
+
* it is, so that prose and Markdown that mention the name (in backticks,
|
|
317
|
+
* bold, headings, links, quotes, paths and so on) come back byte for byte.
|
|
318
|
+
*
|
|
319
|
+
* The name is found in any case, with any punctuation or spaces between its
|
|
320
|
+
* two words, in full-width, mathematical or other compatibility forms, with
|
|
321
|
+
* Cyrillic or Greek look-alike letters, and with invisible characters,
|
|
322
|
+
* combining marks or variation selectors anywhere in it: in a shadow copy of
|
|
323
|
+
* the text (NFKD, lower case, those characters dropped, look-alikes folded
|
|
324
|
+
* to Latin), mapped back.
|
|
325
|
+
*
|
|
326
|
+
* It could form a tag when, after optional spaces, invisible characters and
|
|
327
|
+
* one optional slash (or look-alike), an opening angle bracket stands before
|
|
328
|
+
* it: `<`, a look-alike (˂, 〈, ‹, ⟨, ...) or a compatibility form of one
|
|
329
|
+
* (<, ﹤, ...). That bracket becomes `[`, so `</untrusted_content>` reads as
|
|
330
|
+
* `[/untrusted_content>`: still legible, no longer a tag. When `<` stands
|
|
331
|
+
* there instead, or the name (not glued to a word, path, URL or anchor) is
|
|
332
|
+
* followed by the rest of a tag (`>`, a look-alike or `>`, right after it
|
|
333
|
+
* or after attributes; see closesTag), `[` goes just before the name. A
|
|
334
|
+
* name right after `[` is already
|
|
335
|
+
* defused and stays as it is, so running this again changes nothing.
|
|
336
|
+
*
|
|
337
|
+
* This is a second line of defence: the first is the block id, which text
|
|
338
|
+
* written before the call can't know. Linear in the length of the text.
|
|
339
|
+
*/
|
|
340
|
+
export function neutralize(text) {
|
|
341
|
+
if (text.length < 'untrustedcontent'.length)
|
|
342
|
+
return text;
|
|
343
|
+
const cache = new Map();
|
|
344
|
+
const spans = nameSpans(text, cache);
|
|
345
|
+
if (spans.length === 0)
|
|
346
|
+
return text;
|
|
347
|
+
const memo = new Map();
|
|
348
|
+
// Edits by position: replace the character there (of this width) with "[", or insert "[".
|
|
349
|
+
const edits = new Map();
|
|
350
|
+
for (const [start, end] of spans) {
|
|
351
|
+
let j = start;
|
|
352
|
+
let slash = false;
|
|
353
|
+
for (;;) {
|
|
354
|
+
const prev = before(text, j);
|
|
355
|
+
if (prev === undefined)
|
|
356
|
+
break;
|
|
357
|
+
if (GAP.test(prev.ch)) {
|
|
358
|
+
j -= prev.len;
|
|
359
|
+
}
|
|
360
|
+
else if (!slash && SLASH.test(prev.ch)) {
|
|
361
|
+
slash = true;
|
|
362
|
+
j -= prev.len;
|
|
363
|
+
}
|
|
364
|
+
else {
|
|
365
|
+
break;
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
const opener = before(text, j);
|
|
369
|
+
if (opener?.ch === '[')
|
|
370
|
+
continue;
|
|
371
|
+
if (opener !== undefined && isBracket(opener.ch, OPENERS, cache)) {
|
|
372
|
+
edits.set(j - opener.len, opener.len);
|
|
373
|
+
continue;
|
|
374
|
+
}
|
|
375
|
+
if (OPEN_ENTITY.test(text.slice(Math.max(0, j - ENTITY_REACH), j))) {
|
|
376
|
+
edits.set(start, 0);
|
|
377
|
+
continue;
|
|
378
|
+
}
|
|
379
|
+
// Glued (no space) to the end of a word, path, URL or anchor: not a tag of the name.
|
|
380
|
+
let glued = before(text, start);
|
|
381
|
+
if (glued !== undefined && SLASH.test(glued.ch))
|
|
382
|
+
glued = before(text, start - glued.len);
|
|
383
|
+
if (glued !== undefined && WORDLIKE.test(glued.ch))
|
|
384
|
+
continue;
|
|
385
|
+
if (closesTag(text, end, cache, memo))
|
|
386
|
+
edits.set(start, 0);
|
|
387
|
+
}
|
|
388
|
+
let out = '';
|
|
389
|
+
let last = 0;
|
|
390
|
+
for (const pos of [...edits.keys()].sort((a, b) => a - b)) {
|
|
391
|
+
if (pos < last)
|
|
392
|
+
continue;
|
|
393
|
+
out += text.slice(last, pos) + '[';
|
|
394
|
+
last = pos + (edits.get(pos) ?? 0);
|
|
395
|
+
}
|
|
396
|
+
return out + text.slice(last);
|
|
397
|
+
}
|
|
398
|
+
// ---------------------------------------------------------------------------
|
|
399
|
+
// Wrapping
|
|
400
|
+
// ---------------------------------------------------------------------------
|
|
401
|
+
/** Cuts `text` to at most `max` characters without splitting a surrogate pair. */
|
|
402
|
+
function cut(text, max) {
|
|
403
|
+
let end = Math.max(0, max);
|
|
404
|
+
const code = text.charCodeAt(end - 1);
|
|
405
|
+
if (code >= 0xd800 && code <= 0xdbff)
|
|
406
|
+
end -= 1;
|
|
407
|
+
return text.slice(0, end);
|
|
408
|
+
}
|
|
409
|
+
function attribute(value) {
|
|
410
|
+
const flat = neutralize(value)
|
|
411
|
+
// Control characters and line breaks would let a value spill out of its attribute line.
|
|
412
|
+
// eslint-disable-next-line no-control-regex -- stripping control characters is the point
|
|
413
|
+
.replace(/[\u0000-\u001F\u007F-\u009F\u{2028}\u{2029}]+/gu, ' ')
|
|
414
|
+
.trim();
|
|
415
|
+
return cut(flat, MAX_ATTRIBUTE_CHARS)
|
|
416
|
+
.replace(/&/g, '&')
|
|
417
|
+
.replace(/"/g, '"')
|
|
418
|
+
.replace(/</g, '<')
|
|
419
|
+
.replace(/>/g, '>');
|
|
420
|
+
}
|
|
421
|
+
/** The opening tag of a block with this id and these attributes. */
|
|
422
|
+
function openTag(id, options) {
|
|
423
|
+
const attrs = [`id="${id}"`, `source="${attribute(options.source)}"`];
|
|
424
|
+
if (options.author !== undefined && options.author !== '') {
|
|
425
|
+
attrs.push(`author="${attribute(options.author)}"`);
|
|
426
|
+
}
|
|
427
|
+
for (const [key, value] of Object.entries(options.attributes ?? {})) {
|
|
428
|
+
if (value === undefined || value === null || value === '')
|
|
429
|
+
continue;
|
|
430
|
+
if (!ATTRIBUTE_NAME.test(key) || key === 'id' || key === 'source' || key === 'author') {
|
|
431
|
+
throw new Error(`Bad untrusted_content attribute name: ${key}`);
|
|
432
|
+
}
|
|
433
|
+
attrs.push(`${key}="${attribute(value)}"`);
|
|
434
|
+
}
|
|
435
|
+
return `<${UNTRUSTED_TAG} ${attrs.join(' ')}>`;
|
|
436
|
+
}
|
|
437
|
+
/**
|
|
438
|
+
* Wraps people-written text for an agent. Long text is cut, and a note
|
|
439
|
+
* outside the block (so it is the server speaking, not the author) says how
|
|
440
|
+
* much was cut and how to read the rest.
|
|
441
|
+
*/
|
|
442
|
+
export function wrapUntrusted(text, options) {
|
|
443
|
+
const max = options.maxChars ?? DEFAULT_MAX_CHARS;
|
|
444
|
+
const truncated = text.length > max;
|
|
445
|
+
const body = neutralize(truncated ? cut(text, max) : text);
|
|
446
|
+
const id = currentBlockId();
|
|
447
|
+
let out = `${openTag(id, options)}\n${body}\n</${UNTRUSTED_TAG} id="${id}">`;
|
|
448
|
+
if (truncated) {
|
|
449
|
+
out += `\n[Cut at ${max} of ${text.length} characters. ${options.moreHint ?? DEFAULT_MORE_HINT}]`;
|
|
450
|
+
}
|
|
451
|
+
return out;
|
|
452
|
+
}
|
|
453
|
+
/**
|
|
454
|
+
* Flattens a short people-written label (a person's, project's or org's
|
|
455
|
+
* name) to one defused line of at most `maxChars` characters. In text
|
|
456
|
+
* output, labels go inside a block (wrapLines in the tools); free text
|
|
457
|
+
* (titles, descriptions, comments, pages, messages) uses wrapUntrusted.
|
|
458
|
+
*/
|
|
459
|
+
export function sanitizeLabel(text, maxChars = 200) {
|
|
460
|
+
const flat = neutralize(text)
|
|
461
|
+
// eslint-disable-next-line no-control-regex -- stripping control characters is the point
|
|
462
|
+
.replace(/[\u0000-\u001F\u007F-\u009F\u{2028}\u{2029}]+/gu, ' ')
|
|
463
|
+
.replace(/\s+/g, ' ')
|
|
464
|
+
.trim();
|
|
465
|
+
return flat.length > maxChars ? cut(flat, maxChars - 1) + '…' : flat;
|
|
466
|
+
}
|
package/dist/version.js
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
/** The server's name, as reported to MCP clients and in the User-Agent header. */
|
|
3
|
+
export const SERVER_NAME = 'buildit-mcp';
|
|
4
|
+
function readVersion() {
|
|
5
|
+
// Both src/ (tests) and dist/ (the built server) sit one level below package.json.
|
|
6
|
+
const raw = readFileSync(new URL('../package.json', import.meta.url), 'utf8');
|
|
7
|
+
const parsed = JSON.parse(raw);
|
|
8
|
+
return typeof parsed.version === 'string' ? parsed.version : '0.0.0';
|
|
9
|
+
}
|
|
10
|
+
/** The server's own version, from package.json. */
|
|
11
|
+
export const SERVER_VERSION = readVersion();
|
|
12
|
+
/** Compares dotted numeric versions ("1.2.10" > "1.2.9"); pre-release tags are ignored. */
|
|
13
|
+
export function compareVersions(a, b) {
|
|
14
|
+
const parts = (v) => v
|
|
15
|
+
.replace(/^v/, '')
|
|
16
|
+
.split(/[-+]/, 1)
|
|
17
|
+
.join('')
|
|
18
|
+
.split('.')
|
|
19
|
+
.map((p) => Number.parseInt(p, 10) || 0);
|
|
20
|
+
const pa = parts(a);
|
|
21
|
+
const pb = parts(b);
|
|
22
|
+
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
|
|
23
|
+
const d = (pa[i] ?? 0) - (pb[i] ?? 0);
|
|
24
|
+
if (d !== 0)
|
|
25
|
+
return d < 0 ? -1 : 1;
|
|
26
|
+
}
|
|
27
|
+
return 0;
|
|
28
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,77 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kammcs/buildit-mcp",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.2.0",
|
|
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
|
+
"license": "MIT",
|
|
6
|
+
"author": "Kamm Creative Solutions",
|
|
7
|
+
"homepage": "https://github.com/kammcs/buildit-mcp#readme",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/kammcs/buildit-mcp.git"
|
|
11
|
+
},
|
|
12
|
+
"bugs": "https://github.com/kammcs/buildit-mcp/issues",
|
|
13
|
+
"keywords": [
|
|
14
|
+
"mcp",
|
|
15
|
+
"model-context-protocol",
|
|
16
|
+
"buildit",
|
|
17
|
+
"agents"
|
|
18
|
+
],
|
|
19
|
+
"mcpName": "io.github.kammcs/buildit-mcp",
|
|
20
|
+
"type": "module",
|
|
21
|
+
"bin": {
|
|
22
|
+
"buildit-mcp": "dist/index.js"
|
|
23
|
+
},
|
|
24
|
+
"main": "dist/index.js",
|
|
25
|
+
"files": [
|
|
26
|
+
"dist",
|
|
27
|
+
"README.md",
|
|
28
|
+
"LICENSE",
|
|
29
|
+
"SECURITY.md",
|
|
30
|
+
"CHANGELOG.md"
|
|
31
|
+
],
|
|
32
|
+
"publishConfig": {
|
|
33
|
+
"access": "public"
|
|
34
|
+
},
|
|
35
|
+
"engines": {
|
|
36
|
+
"node": ">=20"
|
|
37
|
+
},
|
|
38
|
+
"scripts": {
|
|
39
|
+
"build": "node scripts/clean.mjs && tsc -p tsconfig.build.json",
|
|
40
|
+
"test": "vitest run",
|
|
41
|
+
"lint": "tsc -p tsconfig.json --noEmit && eslint .",
|
|
42
|
+
"format": "prettier --write .",
|
|
43
|
+
"format:check": "prettier --check .",
|
|
44
|
+
"check": "npm run lint && npm run format:check && npm run build && npm run test && npm run privacy:pack",
|
|
45
|
+
"inspect": "npm run build && tsx scripts/inspect.ts",
|
|
46
|
+
"inspect:docker": "npm run build && docker build -t buildit-mcp:inspect . && tsx scripts/inspect.ts --image buildit-mcp:inspect",
|
|
47
|
+
"image:publish": "tsx scripts/publish-image.ts",
|
|
48
|
+
"bundle": "npm run build && tsx scripts/bundle.ts",
|
|
49
|
+
"conformance": "npm run build && tsx scripts/conformance.ts",
|
|
50
|
+
"generate": "tsx scripts/generate.ts",
|
|
51
|
+
"privacy": "tsx scripts/privacy.ts",
|
|
52
|
+
"prepublishOnly": "npm run check",
|
|
53
|
+
"privacy:pack": "tsx scripts/privacy.ts --pack"
|
|
54
|
+
},
|
|
55
|
+
"dependencies": {
|
|
56
|
+
"@hono/node-server": "2.1.3",
|
|
57
|
+
"@modelcontextprotocol/hono": "2.0.2",
|
|
58
|
+
"@modelcontextprotocol/server": "2.3.1",
|
|
59
|
+
"hono": "4.13.13",
|
|
60
|
+
"zod": "4.6.5"
|
|
61
|
+
},
|
|
62
|
+
"devDependencies": {
|
|
63
|
+
"@eslint/js": "10.0.1",
|
|
64
|
+
"@modelcontextprotocol/client": "2.3.1",
|
|
65
|
+
"@modelcontextprotocol/conformance": "0.2.0-alpha.12",
|
|
66
|
+
"@modelcontextprotocol/inspector": "2.11.0",
|
|
67
|
+
"@types/node": "22.20.5",
|
|
68
|
+
"eslint": "10.12.0",
|
|
69
|
+
"eslint-config-prettier": "10.1.8",
|
|
70
|
+
"globals": "17.13.0",
|
|
71
|
+
"prettier": "3.9.9",
|
|
72
|
+
"tsx": "4.23.15",
|
|
73
|
+
"typescript": "6.0.3",
|
|
74
|
+
"typescript-eslint": "8.71.1",
|
|
75
|
+
"vitest": "5.0.3"
|
|
76
|
+
}
|
|
77
|
+
}
|