@withone/mcp 1.2.4 → 1.3.1
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/README.md +3 -0
- package/build/client.js +90 -12
- package/build/client.test.js +101 -0
- package/build/helpers.js +105 -0
- package/build/index.js +16 -3
- package/build/knowledge-response.test.js +73 -0
- package/build/knowledge-sections.js +558 -0
- package/build/knowledge-sections.test.js +359 -0
- package/build/schemas.js +5 -2
- package/package.json +3 -1
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
import { describe, it } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import { parseSections, buildDigest, renderWhole, renderDigestNotice, renderDigestBanner, collapseSections, omittedSections, findSections, selectSections, parseSectionFlag, classifyHeading, flattenSections, sectionText, slugify, } from './knowledge-sections.js';
|
|
7
|
+
// Pure string-in/structure-out module: no home-directory access, no sandbox needed.
|
|
8
|
+
const fixturesDir = path.join(path.dirname(fileURLToPath(import.meta.url)), 'test-support', 'fixtures');
|
|
9
|
+
const fixture = (name) => fs.readFileSync(path.join(fixturesDir, name), 'utf-8');
|
|
10
|
+
const GITHUB = fixture('knowledge-github-create-issue.md'); // ~25k, canonical template
|
|
11
|
+
const STRIPE = fixture('knowledge-stripe-create-customer.md'); // ~10k, canonical template
|
|
12
|
+
const GMAIL = fixture('knowledge-gmail-send-email.md'); // ~10k, custom-action template
|
|
13
|
+
describe('parseSections', () => {
|
|
14
|
+
it('takes the first H1 as the title and promotes its H2s to top level', () => {
|
|
15
|
+
const doc = parseSections(GITHUB);
|
|
16
|
+
assert.equal(doc.title, 'Create an Issue for a Repository');
|
|
17
|
+
assert.equal(doc.preface, '');
|
|
18
|
+
assert.deepEqual(doc.sections.map((s) => s.heading).slice(0, 6), ['Prerequisites', 'Method', 'URL', 'Headers', 'Description', 'Enforcement Rules']);
|
|
19
|
+
assert.ok(doc.sections.every((s) => s.level === 2));
|
|
20
|
+
});
|
|
21
|
+
it('parses a CRLF document identically to LF (Windows checkouts, scraped docs)', () => {
|
|
22
|
+
const lf = GITHUB.replace(/\r\n?/g, '\n');
|
|
23
|
+
const crlf = lf.replace(/\n/g, '\r\n');
|
|
24
|
+
// Without normalisation the trailing \r defeats the heading regex, so the
|
|
25
|
+
// title is empty and nothing is sectioned.
|
|
26
|
+
assert.equal(parseSections(crlf).title, 'Create an Issue for a Repository');
|
|
27
|
+
assert.deepEqual(parseSections(crlf), parseSections(lf));
|
|
28
|
+
});
|
|
29
|
+
it('nests H3s under their H2', () => {
|
|
30
|
+
const doc = parseSections(GITHUB);
|
|
31
|
+
const response = doc.sections.find((s) => s.heading === 'Response');
|
|
32
|
+
assert.equal(response.children.length, 7);
|
|
33
|
+
assert.equal(response.children[0].heading, 'Success Response (201 Created)');
|
|
34
|
+
assert.ok(response.children.slice(1).every((c) => c.heading.startsWith('Error Response')));
|
|
35
|
+
});
|
|
36
|
+
it('handles the custom-action template (Endpoint + Request Body with H3 children)', () => {
|
|
37
|
+
const doc = parseSections(GMAIL);
|
|
38
|
+
assert.equal(doc.title, 'Send Email');
|
|
39
|
+
const body = doc.sections.find((s) => s.heading === 'Request Body');
|
|
40
|
+
assert.deepEqual(body.children.map((c) => c.heading), ['Required Request Body Fields', 'Optional Request Body Fields']);
|
|
41
|
+
});
|
|
42
|
+
it('round-trips: rendering the whole tree reproduces the source text', () => {
|
|
43
|
+
for (const src of [GITHUB, STRIPE, GMAIL]) {
|
|
44
|
+
const doc = parseSections(src);
|
|
45
|
+
// Rendering joins blocks with a blank line and parseSections normalises
|
|
46
|
+
// CRLF to LF, so compare modulo blank-line runs and line-ending style.
|
|
47
|
+
const norm = (s) => s.replace(/\r\n?/g, '\n').replace(/\n{2,}/g, '\n').trim();
|
|
48
|
+
assert.equal(norm(renderWhole(doc)), norm(src));
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
it('ignores # lines inside fenced code blocks', () => {
|
|
52
|
+
const md = ['# T', '', '## Real', '```bash', '# not a heading', '~~~ still inside', '```', 'after', '## Next', ''].join('\n');
|
|
53
|
+
const doc = parseSections(md);
|
|
54
|
+
assert.deepEqual(doc.sections.map((s) => s.heading), ['Real', 'Next']);
|
|
55
|
+
assert.ok(doc.sections[0].text.includes('# not a heading'));
|
|
56
|
+
});
|
|
57
|
+
it('keeps a second H1 as a top-level section rather than a new title', () => {
|
|
58
|
+
const md = '# One\n\n## A\ntext\n\n# Two\n\n## B\nmore\n';
|
|
59
|
+
const doc = parseSections(md);
|
|
60
|
+
assert.equal(doc.title, 'One');
|
|
61
|
+
assert.deepEqual(doc.sections.map((s) => `${s.level}:${s.heading}`), ['2:A', '1:Two']);
|
|
62
|
+
assert.equal(doc.sections[1].children[0].heading, 'B');
|
|
63
|
+
});
|
|
64
|
+
it('returns no sections and an empty title for text without headings', () => {
|
|
65
|
+
const doc = parseSections('just prose\nno headings');
|
|
66
|
+
assert.equal(doc.title, '');
|
|
67
|
+
assert.equal(doc.sections.length, 0);
|
|
68
|
+
assert.equal(doc.preface, 'just prose\nno headings');
|
|
69
|
+
});
|
|
70
|
+
it('de-duplicates ids and slugs headings predictably', () => {
|
|
71
|
+
assert.equal(slugify('Success Response (201 Created)'), 'success-response-201-created');
|
|
72
|
+
assert.equal(slugify('Optional Request Body Fields (application/x-www-form-urlencoded)'), 'optional-request-body-fields-application-x-www-form-urlencoded');
|
|
73
|
+
const doc = parseSections('# T\n## Notes\n## Notes\n## Notes\n');
|
|
74
|
+
assert.deepEqual(doc.sections.map((s) => s.id), ['notes', 'notes-2', 'notes-3']);
|
|
75
|
+
});
|
|
76
|
+
it('measures chars including children', () => {
|
|
77
|
+
const doc = parseSections(GITHUB);
|
|
78
|
+
const response = doc.sections.find((s) => s.heading === 'Response');
|
|
79
|
+
assert.equal(response.chars, sectionText(response).length + 0 || response.chars);
|
|
80
|
+
assert.ok(response.chars > 15_000, `response subtree is ${response.chars}`);
|
|
81
|
+
const total = doc.sections.reduce((n, s) => n + s.chars, 0);
|
|
82
|
+
assert.ok(total <= GITHUB.length && total > GITHUB.length * 0.95);
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
describe('classifyHeading', () => {
|
|
86
|
+
it('marks request-building sections essential and the size drivers deferred', () => {
|
|
87
|
+
const essential = ['Method', 'URL', 'Endpoint', 'Headers', 'Description', 'Prerequisites', 'Enforcement Rules',
|
|
88
|
+
'Required Path Parameters', 'Required Request Body Fields', 'Required Query Parameters', 'Request Body',
|
|
89
|
+
'Sample Request', 'Gotchas', 'Error Handling'];
|
|
90
|
+
const deferred = ['Response', 'Success Response (201 Created)', 'Error Response (404 Not Found)', 'Response Fields',
|
|
91
|
+
'Optional Request Body Fields', 'Optional Request Body Fields (application/x-www-form-urlencoded)',
|
|
92
|
+
'Optional Query Parameters', 'Example Usage', 'Example 1: Simple Message', 'Behavior', 'Notes', 'Document.footnotes'];
|
|
93
|
+
for (const h of essential)
|
|
94
|
+
assert.equal(classifyHeading(h), 'essential', h);
|
|
95
|
+
for (const h of deferred)
|
|
96
|
+
assert.equal(classifyHeading(h), 'deferred', h);
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
describe('buildDigest', () => {
|
|
100
|
+
it('returns small documents whole, untruncated, with every section flagged included', () => {
|
|
101
|
+
const doc = parseSections(STRIPE);
|
|
102
|
+
const d = buildDigest(doc, { wholeDocThreshold: 20_000 });
|
|
103
|
+
assert.equal(d.truncated, false);
|
|
104
|
+
assert.equal(d.omitted, 0);
|
|
105
|
+
assert.equal(d.omittedChars, 0);
|
|
106
|
+
assert.ok(d.sections.every((s) => s.included === true));
|
|
107
|
+
assert.equal(d.sections.length, flattenSections(doc.sections).length);
|
|
108
|
+
assert.ok(d.markdown.includes('## Optional Request Body Fields'));
|
|
109
|
+
});
|
|
110
|
+
it('returns a document with no headings whole', () => {
|
|
111
|
+
const d = buildDigest(parseSections('a'.repeat(50_000)));
|
|
112
|
+
assert.equal(d.truncated, false);
|
|
113
|
+
assert.equal(d.markdown.length, 50_000);
|
|
114
|
+
});
|
|
115
|
+
it('keeps essentials, defers the big Response block, and lists everything omitted', () => {
|
|
116
|
+
const doc = parseSections(GITHUB);
|
|
117
|
+
const d = buildDigest(doc);
|
|
118
|
+
assert.equal(d.truncated, true);
|
|
119
|
+
assert.ok(d.markdown.length < GITHUB.length / 2, `digest is ${d.markdown.length} of ${GITHUB.length}`);
|
|
120
|
+
const status = Object.fromEntries(d.sections.map((s) => [s.heading, s.included]));
|
|
121
|
+
for (const h of ['Prerequisites', 'Method', 'URL', 'Headers', 'Description', 'Enforcement Rules',
|
|
122
|
+
'Required Path Parameters', 'Required Request Body Fields', 'Sample Request', 'Gotchas', 'Error Handling']) {
|
|
123
|
+
assert.equal(status[h], true, h);
|
|
124
|
+
}
|
|
125
|
+
assert.equal(status['Success Response (201 Created)'], false);
|
|
126
|
+
assert.equal(status['Response Fields'], false);
|
|
127
|
+
assert.ok(!d.markdown.includes('### Success Response (201 Created)'));
|
|
128
|
+
assert.ok(d.markdown.includes('## Required Request Body Fields'));
|
|
129
|
+
// The TOC covers every section, not just the omitted ones.
|
|
130
|
+
assert.equal(d.sections.length, flattenSections(doc.sections).length);
|
|
131
|
+
assert.ok(d.omitted > 0);
|
|
132
|
+
assert.ok(d.omittedChars > 15_000);
|
|
133
|
+
});
|
|
134
|
+
it('back-fills deferred sections in priority order while they fit the budget', () => {
|
|
135
|
+
const doc = parseSections(GITHUB);
|
|
136
|
+
const d = buildDigest(doc, { wholeDocThreshold: 0, budget: 8_000 });
|
|
137
|
+
const status = Object.fromEntries(d.sections.map((s) => [s.heading, s.included]));
|
|
138
|
+
// Optional fields (~1k) fit and are the first to be pulled back in.
|
|
139
|
+
assert.equal(status['Optional Request Body Fields'], true);
|
|
140
|
+
// The 15k success response never fits an 8k budget.
|
|
141
|
+
assert.equal(status['Success Response (201 Created)'], false);
|
|
142
|
+
const tight = buildDigest(doc, { wholeDocThreshold: 0, budget: 0 });
|
|
143
|
+
const tightStatus = Object.fromEntries(tight.sections.map((s) => [s.heading, s.included]));
|
|
144
|
+
assert.equal(tightStatus['Optional Request Body Fields'], false);
|
|
145
|
+
assert.equal(tightStatus['Method'], true, 'essentials survive a zero budget');
|
|
146
|
+
});
|
|
147
|
+
it('cuts an oversized essential section and marks it partial', () => {
|
|
148
|
+
const big = '# T\n\n## Method\nPOST\n\n## Required Request Body Fields\n' + '| a | b |\n'.repeat(2_000) + '\n## Notes\nn\n';
|
|
149
|
+
const doc = parseSections(big);
|
|
150
|
+
const d = buildDigest(doc, { wholeDocThreshold: 0, sectionCap: 500, budget: 0 });
|
|
151
|
+
const req = d.sections.find((s) => s.heading === 'Required Request Body Fields');
|
|
152
|
+
assert.equal(req.included, 'partial');
|
|
153
|
+
assert.ok(d.markdown.includes('_[truncated'));
|
|
154
|
+
assert.ok(d.markdown.includes('section: "required-request-body-fields"'));
|
|
155
|
+
assert.ok(d.truncated);
|
|
156
|
+
assert.ok(d.omittedChars > 15_000);
|
|
157
|
+
});
|
|
158
|
+
it('keeps essential children of an essential parent but defers deferred children (custom template)', () => {
|
|
159
|
+
const doc = parseSections(GMAIL);
|
|
160
|
+
const d = buildDigest(doc, { wholeDocThreshold: 0, budget: 0 });
|
|
161
|
+
const status = Object.fromEntries(d.sections.map((s) => [s.heading, s.included]));
|
|
162
|
+
assert.equal(status['Request Body'], true);
|
|
163
|
+
assert.equal(status['Required Request Body Fields'], true);
|
|
164
|
+
assert.equal(status['Optional Request Body Fields'], false);
|
|
165
|
+
assert.ok(d.markdown.includes('### Required Request Body Fields'));
|
|
166
|
+
assert.ok(!d.markdown.includes('### Optional Request Body Fields'));
|
|
167
|
+
});
|
|
168
|
+
it('keeps required fields nested under a deferred sub-heading (Notion option layout)', () => {
|
|
169
|
+
const md = [
|
|
170
|
+
'# Update Page', '', '## Method', 'PATCH', '', '## Request Body', 'Pick one option.', '',
|
|
171
|
+
'### Option A: Replace everything', '#### Required Request Body Fields (Replace)', '| a |', '#### Optional Fields (Replace)', '| opt |',
|
|
172
|
+
'### Option B: Search and replace', 'intro b', '#### Required Fields inside `update_content`', '| b |', '#### Examples for B', 'ex',
|
|
173
|
+
'## Response', 'x'.repeat(9_000), '',
|
|
174
|
+
].join('\n');
|
|
175
|
+
const d = buildDigest(parseSections(md), { wholeDocThreshold: 0, budget: 0 });
|
|
176
|
+
const st = Object.fromEntries(d.sections.map((s) => [s.heading, s.included]));
|
|
177
|
+
assert.equal(st['Option A: Replace everything'], true, 'scaffolding kept');
|
|
178
|
+
assert.equal(st['Required Request Body Fields (Replace)'], true);
|
|
179
|
+
assert.equal(st['Optional Fields (Replace)'], false);
|
|
180
|
+
assert.equal(st['Option B: Search and replace'], true);
|
|
181
|
+
assert.equal(st['Required Fields inside `update_content`'], true);
|
|
182
|
+
assert.equal(st['Examples for B'], false);
|
|
183
|
+
assert.equal(st['Response'], false);
|
|
184
|
+
assert.ok(d.markdown.includes('intro b'));
|
|
185
|
+
assert.ok(!d.markdown.includes('| opt |'));
|
|
186
|
+
});
|
|
187
|
+
it('never back-fills appended H1 chunks even when they would fit the budget', () => {
|
|
188
|
+
const md = [
|
|
189
|
+
'# Action', '', '## Method', 'POST', '', '## Response', 'r'.repeat(9_000), '',
|
|
190
|
+
'# Data Models — Chunk 2/7', '', '# TinyType', '## Description', 'small', '## Fields', '| f |', '',
|
|
191
|
+
].join('\n');
|
|
192
|
+
const d = buildDigest(parseSections(md), { wholeDocThreshold: 0, budget: 100_000 });
|
|
193
|
+
const st = Object.fromEntries(d.sections.map((s) => [s.heading, s.included]));
|
|
194
|
+
assert.equal(st['Response'], true, 'H2 deferred sections are back-filled');
|
|
195
|
+
assert.equal(st['TinyType'], false, 'appendix H1s are not');
|
|
196
|
+
assert.equal(st['Data Models — Chunk 2/7'], false);
|
|
197
|
+
assert.ok(d.truncated);
|
|
198
|
+
});
|
|
199
|
+
it('treats GraphQL request-building headings as essential', () => {
|
|
200
|
+
for (const h of ['GraphQL Operation', 'Request Variables', 'Path Parameters', 'Query Parameters']) {
|
|
201
|
+
assert.equal(classifyHeading(h), 'essential', h);
|
|
202
|
+
}
|
|
203
|
+
assert.equal(classifyHeading('Type'), 'deferred');
|
|
204
|
+
});
|
|
205
|
+
it('falls back to the whole document when the digest would include everything anyway', () => {
|
|
206
|
+
const doc = parseSections(STRIPE);
|
|
207
|
+
const d = buildDigest(doc, { wholeDocThreshold: 0, budget: 1_000_000 });
|
|
208
|
+
assert.equal(d.truncated, false);
|
|
209
|
+
assert.equal(d.markdown, renderWhole(doc));
|
|
210
|
+
});
|
|
211
|
+
});
|
|
212
|
+
describe('renderDigestNotice', () => {
|
|
213
|
+
it('is empty for an untruncated digest', () => {
|
|
214
|
+
const d = buildDigest(parseSections(STRIPE), { wholeDocThreshold: 1_000_000 });
|
|
215
|
+
assert.equal(renderDigestNotice(d, 'stripe', 'id'), '');
|
|
216
|
+
});
|
|
217
|
+
it('names only top-most omitted sections and shows both load commands', () => {
|
|
218
|
+
const d = buildDigest(parseSections(GITHUB), { wholeDocThreshold: 0, budget: 0 });
|
|
219
|
+
const notice = renderDigestNotice(d, 'github', 'conn_mod_def::X::Y');
|
|
220
|
+
assert.match(notice, /This is a digest, not the full document/);
|
|
221
|
+
assert.match(notice, /get_one_action_knowledge again for github \/ conn_mod_def::X::Y with section: "/);
|
|
222
|
+
assert.match(notice, /full: true/);
|
|
223
|
+
assert.ok(notice.includes('Response,') || notice.includes('Response.'), 'lists the omitted Response parent');
|
|
224
|
+
assert.ok(!notice.includes('Success Response (201 Created)'), 'children of an omitted parent are implied');
|
|
225
|
+
assert.ok(notice.includes('Response Fields'));
|
|
226
|
+
});
|
|
227
|
+
});
|
|
228
|
+
describe('renderDigestBanner', () => {
|
|
229
|
+
it('is empty when nothing was omitted and one line otherwise', () => {
|
|
230
|
+
assert.equal(renderDigestBanner(buildDigest(parseSections(STRIPE), { wholeDocThreshold: 1_000_000 })), '');
|
|
231
|
+
const b = renderDigestBanner(buildDigest(parseSections(GITHUB), { wholeDocThreshold: 0, budget: 0 }));
|
|
232
|
+
assert.match(b, /^> \*\*Digest\.\*\* \d+ sections \([\d,]+ chars\) omitted/);
|
|
233
|
+
assert.ok(!b.includes('\n'));
|
|
234
|
+
});
|
|
235
|
+
});
|
|
236
|
+
describe('collapseSections', () => {
|
|
237
|
+
const mk = (level, i) => ({ id: `s${i}`, heading: `H${i}`, level, chars: 10, included: false });
|
|
238
|
+
it('leaves a short list alone', () => {
|
|
239
|
+
const list = [mk(2, 1), mk(3, 2), mk(2, 3)];
|
|
240
|
+
const r = collapseSections(list, 60);
|
|
241
|
+
assert.equal(r.collapsed, false);
|
|
242
|
+
assert.equal(r.sections, list);
|
|
243
|
+
});
|
|
244
|
+
it('drops the deepest levels first and counts hidden descendants on the surviving ancestor', () => {
|
|
245
|
+
// 5 H2s, each with 20 H3s = 105 entries
|
|
246
|
+
const list = [];
|
|
247
|
+
let i = 0;
|
|
248
|
+
for (let a = 0; a < 5; a++) {
|
|
249
|
+
list.push(mk(2, i++));
|
|
250
|
+
for (let b = 0; b < 20; b++)
|
|
251
|
+
list.push(mk(3, i++));
|
|
252
|
+
}
|
|
253
|
+
const r = collapseSections(list, 60);
|
|
254
|
+
assert.equal(r.collapsed, true);
|
|
255
|
+
assert.equal(r.sections.length, 5);
|
|
256
|
+
assert.ok(r.sections.every((s) => s.level === 2 && s.children === 20));
|
|
257
|
+
});
|
|
258
|
+
it('collapses the real 400-heading Notion-style tree to a manageable size', () => {
|
|
259
|
+
// Synthesise: canonical doc + 40 appended H1 chunks each with 10 H2s.
|
|
260
|
+
const parts = ['# Big', '## Method', 'POST', '## Response', 'r'];
|
|
261
|
+
for (let c = 0; c < 40; c++) {
|
|
262
|
+
parts.push(`# Chunk ${c}`);
|
|
263
|
+
for (let h = 0; h < 10; h++)
|
|
264
|
+
parts.push(`## Type ${c}-${h}`, 'x');
|
|
265
|
+
}
|
|
266
|
+
const d = buildDigest(parseSections(parts.join('\n')), { wholeDocThreshold: 0 });
|
|
267
|
+
assert.equal(d.sections.length, 442);
|
|
268
|
+
const r = collapseSections(d.sections);
|
|
269
|
+
assert.ok(r.collapsed);
|
|
270
|
+
assert.equal(r.sections.length, 42, 'title-level H2s and the 40 chunk H1s');
|
|
271
|
+
const chunk = r.sections.find((s) => s.heading === 'Chunk 3');
|
|
272
|
+
assert.equal(chunk.children, 10);
|
|
273
|
+
});
|
|
274
|
+
});
|
|
275
|
+
describe('omittedSections', () => {
|
|
276
|
+
it('lists only what the digest left out', () => {
|
|
277
|
+
const d = buildDigest(parseSections(GITHUB), { wholeDocThreshold: 0, budget: 0 });
|
|
278
|
+
const o = omittedSections(d);
|
|
279
|
+
assert.equal(o.collapsed, false);
|
|
280
|
+
assert.ok(o.sections.length > 0 && o.sections.length < d.sections.length);
|
|
281
|
+
assert.ok(o.sections.every((s) => s.included !== true));
|
|
282
|
+
assert.ok(o.sections.some((s) => s.id === 'response-fields'));
|
|
283
|
+
assert.ok(!o.sections.some((s) => s.id === 'method'));
|
|
284
|
+
});
|
|
285
|
+
it('is empty for an untruncated digest', () => {
|
|
286
|
+
assert.deepEqual(omittedSections(buildDigest(parseSections(STRIPE), { wholeDocThreshold: 1_000_000 })).sections, []);
|
|
287
|
+
});
|
|
288
|
+
});
|
|
289
|
+
describe('findSections / selectSections', () => {
|
|
290
|
+
const doc = parseSections(GITHUB);
|
|
291
|
+
const headings = (r) => (r.ok ? r.sections.map((s) => s.heading) : r);
|
|
292
|
+
it('matches by id, exact heading, and case-insensitive heading', () => {
|
|
293
|
+
assert.deepEqual(headings(findSections(doc, 'response-fields')), ['Response Fields']);
|
|
294
|
+
assert.deepEqual(headings(findSections(doc, 'Response Fields')), ['Response Fields']);
|
|
295
|
+
assert.deepEqual(headings(findSections(doc, '"response fields"')), ['Response Fields']);
|
|
296
|
+
assert.deepEqual(headings(findSections(doc, 'success response')), ['Success Response (201 Created)']);
|
|
297
|
+
});
|
|
298
|
+
it('resolves aliases to groups', () => {
|
|
299
|
+
assert.deepEqual(headings(findSections(doc, 'optional')), ['Optional Request Body Fields']);
|
|
300
|
+
assert.deepEqual(headings(findSections(doc, 'required')), ['Required Path Parameters', 'Required Request Body Fields']);
|
|
301
|
+
assert.deepEqual(headings(findSections(doc, 'examples')), ['Example Usage']);
|
|
302
|
+
const errors = headings(findSections(doc, 'errors'));
|
|
303
|
+
assert.ok(errors.length >= 6 && errors.includes('Error Handling'));
|
|
304
|
+
});
|
|
305
|
+
it('returns every copy when a heading is duplicated', () => {
|
|
306
|
+
const dup = parseSections('# T\n## Response\nnot included\n## Notes\nn\n# T again\n## Response\nthe real one\n');
|
|
307
|
+
const r = findSections(dup, 'Response');
|
|
308
|
+
assert.ok(r.ok);
|
|
309
|
+
assert.deepEqual(r.sections.map((s) => s.id), ['response', 'response-2']);
|
|
310
|
+
const byId = findSections(dup, 'response-2');
|
|
311
|
+
assert.ok(byId.ok && byId.sections.length === 1);
|
|
312
|
+
});
|
|
313
|
+
it('labels appended H1 chunks as appendix in the notice', () => {
|
|
314
|
+
const d = buildDigest(parseSections('# T\n## Method\nPOST\n## Response\n' + 'x'.repeat(9000) + '\n# T\n## Response\nagain\n'), { wholeDocThreshold: 0, budget: 0 });
|
|
315
|
+
const n = renderDigestNotice(d, 'p', 'id');
|
|
316
|
+
assert.ok(n.includes('T (appendix)'), n);
|
|
317
|
+
});
|
|
318
|
+
it('a parent match carries its children', () => {
|
|
319
|
+
const r = selectSections(doc, ['response']);
|
|
320
|
+
assert.ok(r.ok);
|
|
321
|
+
assert.ok(r.markdown.includes('### Success Response (201 Created)'));
|
|
322
|
+
assert.ok(r.markdown.includes('### Error Response (404 Resource not found)'));
|
|
323
|
+
assert.ok(r.markdown.startsWith('# Create an Issue for a Repository'));
|
|
324
|
+
});
|
|
325
|
+
it('prefix matches that all share a canonical name return the group', () => {
|
|
326
|
+
const r = findSections(doc, 'error response');
|
|
327
|
+
assert.ok(r.ok);
|
|
328
|
+
assert.equal(r.sections.length, 6);
|
|
329
|
+
});
|
|
330
|
+
it('an ambiguous prefix returns candidates rather than guessing', () => {
|
|
331
|
+
const r = findSections(doc, 'e');
|
|
332
|
+
assert.ok(!r.ok);
|
|
333
|
+
assert.equal(r.reason, 'ambiguous');
|
|
334
|
+
assert.ok(r.candidates.length > 1);
|
|
335
|
+
});
|
|
336
|
+
it('an unknown name returns not-found with the full section list', () => {
|
|
337
|
+
const r = findSections(doc, 'pricing');
|
|
338
|
+
assert.ok(!r.ok);
|
|
339
|
+
assert.equal(r.reason, 'not-found');
|
|
340
|
+
assert.equal(r.candidates.length, flattenSections(doc.sections).length);
|
|
341
|
+
});
|
|
342
|
+
it('selectSections accepts several names, dedupes, and orders by document position', () => {
|
|
343
|
+
const r = selectSections(doc, ['notes', 'Method', 'success-response-201-created', 'response']);
|
|
344
|
+
assert.ok(r.ok);
|
|
345
|
+
assert.deepEqual(r.sections.map((s) => s.heading), ['Method', 'Response', 'Notes']);
|
|
346
|
+
});
|
|
347
|
+
it('selectSections reports the first bad name', () => {
|
|
348
|
+
const r = selectSections(doc, ['method', 'nope']);
|
|
349
|
+
assert.ok(!r.ok);
|
|
350
|
+
assert.equal(r.query, 'nope');
|
|
351
|
+
});
|
|
352
|
+
});
|
|
353
|
+
describe('parseSectionFlag', () => {
|
|
354
|
+
it('splits repeated flags and comma lists', () => {
|
|
355
|
+
assert.deepEqual(parseSectionFlag(undefined), []);
|
|
356
|
+
assert.deepEqual(parseSectionFlag('a, b'), ['a', 'b']);
|
|
357
|
+
assert.deepEqual(parseSectionFlag(['a,b', ' c ', '']), ['a', 'b', 'c']);
|
|
358
|
+
});
|
|
359
|
+
});
|
package/build/schemas.js
CHANGED
|
@@ -25,7 +25,10 @@ export const searchOnePlatformActionsInputSchema = {
|
|
|
25
25
|
*/
|
|
26
26
|
export const getOneActionKnowledgeInputSchema = {
|
|
27
27
|
actionId: z.string().describe("The action ID to get knowledge for (from the actions list returned by search_one_platform_actions). REQUIRED: This tool must be called before execute_one_action to load the action's documentation into context."),
|
|
28
|
-
platform: z.string().describe("The platform name to get knowledge for (e.g., 'ship-station', 'shopify'). This is the kebab-case version of the platform name that comes from the list_one_integrations tool AVAILABLE PLATFORMS section.")
|
|
28
|
+
platform: z.string().describe("The platform name to get knowledge for (e.g., 'ship-station', 'shopify'). This is the kebab-case version of the platform name that comes from the list_one_integrations tool AVAILABLE PLATFORMS section."),
|
|
29
|
+
section: z.string().optional().describe("Load specific section(s) of the documentation by name (e.g. 'Response Fields', or comma-separated 'Response Fields, Optional Request Body Fields'). Large docs return a digest first; use this on a follow-up call to pull a section the digest omitted."),
|
|
30
|
+
full: z.boolean().optional().describe("Return the entire documentation verbatim instead of the token-saving digest. Ignored when `section` is set. Leave unset by default; only set it when you genuinely need every section."),
|
|
31
|
+
toc: z.boolean().optional().describe("Return only the table of contents (every section's id, heading, and size), no bodies. Use it to see everything available when the digest collapsed its list. Ignored when `section` is set.")
|
|
29
32
|
};
|
|
30
33
|
/**
|
|
31
34
|
* Schema for executing One actions
|
|
@@ -111,7 +114,7 @@ export const searchOnePlatformActionsToolConfig = {
|
|
|
111
114
|
};
|
|
112
115
|
export const getOneActionKnowledgeToolConfig = {
|
|
113
116
|
title: "Get Action Knowledge",
|
|
114
|
-
description: "Get comprehensive documentation for a specific action including parameters, requirements, and usage examples. MANDATORY: You MUST call this tool before execute_one_action to understand the action's requirements, parameter structure, caveats, and proper usage. This loads the action documentation into context and is required for successful execution.",
|
|
117
|
+
description: "Get comprehensive documentation for a specific action including parameters, requirements, and usage examples. MANDATORY: You MUST call this tool before execute_one_action to understand the action's requirements, parameter structure, caveats, and proper usage. This loads the action documentation into context and is required for successful execution. Large documents come back as a digest (the sections needed to build a correct request, plus a list of what was omitted); request an omitted section by name with `section`, or the whole document with `full: true`.",
|
|
115
118
|
inputSchema: getOneActionKnowledgeInputSchema
|
|
116
119
|
};
|
|
117
120
|
export const executeOneActionToolConfig = {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@withone/mcp",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.1",
|
|
4
4
|
"description": "A Model Context Protocol Server for One",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
],
|
|
12
12
|
"scripts": {
|
|
13
13
|
"build": "tsc && node -e \"require('fs').chmodSync('build/index.js', '755')\"",
|
|
14
|
+
"test": "node scripts/run-tests.mjs",
|
|
14
15
|
"prepare": "npm run build",
|
|
15
16
|
"watch": "tsc --watch",
|
|
16
17
|
"inspector": "npx @modelcontextprotocol/inspector build/index.js",
|
|
@@ -31,6 +32,7 @@
|
|
|
31
32
|
"@types/express": "^5.0.3",
|
|
32
33
|
"@types/node": "^22.0.0",
|
|
33
34
|
"nodemon": "^3.1.10",
|
|
35
|
+
"tsx": "^4.23.13",
|
|
34
36
|
"typescript": "^5.6.0"
|
|
35
37
|
},
|
|
36
38
|
"author": {
|