@withone/mcp 1.2.3 → 1.3.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/README.md CHANGED
@@ -20,7 +20,7 @@
20
20
  <a href="https://npmjs.com/package/@withone/mcp"><img src="https://img.shields.io/npm/v/%40withone%2Fmcp" alt="npm version"></a>
21
21
  </p>
22
22
 
23
- Connect your AI agents to 500+ apps through a single [MCP](https://modelcontextprotocol.io) server. Search for actions, read documentation, and execute API calls across platforms, without having to manage OAuth tokens or API keys.
23
+ Connect your AI agents to 700+ apps through a single [MCP](https://modelcontextprotocol.io) server. Search for actions, read documentation, and execute API calls across platforms, without having to manage OAuth tokens or API keys.
24
24
 
25
25
  ```bash
26
26
  npm install -g @withone/cli
@@ -31,7 +31,7 @@ That's it. The [One CLI](https://www.npmjs.com/package/@withone/cli) will prompt
31
31
 
32
32
  ## Capabilities
33
33
 
34
- - **500+ platforms.** Gmail, Slack, Shopify, HubSpot, Stripe, Linear, QuickBooks, and [more](https://app.withone.ai/tools).
34
+ - **700+ platforms.** Gmail, Slack, Shopify, HubSpot, Stripe, Linear, QuickBooks, and [more](https://app.withone.ai/tools).
35
35
  - **Natural language execution.** "read my last gmail email", "send a message to #general on Slack"
36
36
  - **Code generation.** "build a form to send emails using Gmail", "create a dashboard that lists my Linear projects"
37
37
  - **No tool bloat.** Only 4 tools exposed regardless of how many platforms or actions you connect. Actions are search-based, so your agent's context window stays clean.
@@ -89,6 +89,9 @@ One-click install:
89
89
  [![Install in VS Code](https://img.shields.io/badge/VS_Code-Install_One-0098FF?style=flat-square&logo=visualstudiocode&logoColor=white)](https://vscode.dev/redirect/mcp/install?name=one&config=%7B%22type%22%3A%20%22http%22%2C%20%22url%22%3A%20%22https%3A%2F%2Fmcp.withone.ai%2Fmcp%22%7D)
90
90
  [![Install in VS Code Insiders](https://img.shields.io/badge/VS_Code_Insiders-Install_One-24bfa5?style=flat-square&logo=visualstudiocode&logoColor=white)](https://insiders.vscode.dev/redirect/mcp/install?name=one&config=%7B%22type%22%3A%20%22http%22%2C%20%22url%22%3A%20%22https%3A%2F%2Fmcp.withone.ai%2Fmcp%22%7D)
91
91
  [![Add to Cursor](https://img.shields.io/badge/Cursor-Add_One-000000?style=flat-square)](https://cursor.com/en/install-mcp?name=one&config=eyJ1cmwiOiAiaHR0cHM6Ly9tY3Aud2l0aG9uZS5haS9tY3AifQ==)
92
+ [![Open in Windsurf](https://img.shields.io/badge/Windsurf-Open_One-09B6A2?style=flat-square)](windsurf://windsurf-mcp-registry?serverName=ai.withone/mcp)
93
+
94
+ The Windsurf link opens One's page in the editor's MCP marketplace, which is served from the official MCP registry where One is published as `ai.withone/mcp`. Windsurf handles the OAuth sign-in itself once you install from that page.
92
95
 
93
96
  ## Manual Installation
94
97
 
package/build/client.js CHANGED
@@ -22,6 +22,11 @@ export class OneClient {
22
22
  connectors = [];
23
23
  isInitialized = false;
24
24
  allowedActionsCache = null;
25
+ // Short-lived cache of fetched action details, keyed by action ID. The
26
+ // knowledge digest and a follow-up section/toc/full call both need the same
27
+ // document, so this lets the follow-up reuse it instead of re-fetching.
28
+ static ACTION_DETAILS_TTL_MS = 5 * 60_000;
29
+ actionDetailsCache = new Map();
25
30
  constructor(optionsOrSecret, baseUrl = "https://api.withone.ai") {
26
31
  if (typeof optionsOrSecret === 'string') {
27
32
  // Legacy constructor: (secret, baseUrl)
@@ -174,6 +179,10 @@ export class OneClient {
174
179
  if (!actionId?.trim()) {
175
180
  throw new Error("Action ID is required");
176
181
  }
182
+ const cached = this.actionDetailsCache.get(actionId);
183
+ if (cached && cached.expiresAt > Date.now()) {
184
+ return cached.details;
185
+ }
177
186
  try {
178
187
  const headers = this.generateHeaders();
179
188
  const url = `${this.baseUrl}/v1/knowledge`;
@@ -188,6 +197,10 @@ export class OneClient {
188
197
  if (actions.length === 0) {
189
198
  throw new Error(`Action with ID ${actionId} not found`);
190
199
  }
200
+ this.actionDetailsCache.set(actionId, {
201
+ details: actions[0],
202
+ expiresAt: Date.now() + OneClient.ACTION_DETAILS_TTL_MS
203
+ });
191
204
  return actions[0];
192
205
  }
193
206
  catch (error) {
package/build/helpers.js CHANGED
@@ -7,6 +7,7 @@
7
7
  * @author One
8
8
  */
9
9
  import axios from 'axios';
10
+ import { parseSections, buildDigest, renderDigestBanner, renderDigestNotice, selectSections, flattenSections, parseSectionFlag, collapseSections, omittedSections, } from './knowledge-sections.js';
10
11
  /**
11
12
  * Paginates through API results by repeatedly calling a fetch function until all data is retrieved.
12
13
  * @param fetchFn - Function that fetches paginated data, takes skip and limit parameters
@@ -60,6 +61,110 @@ export async function fetchPaginatedData(baseUrl, headers, additionalParams) {
60
61
  };
61
62
  return paginateResults(fetchFn);
62
63
  }
64
+ /**
65
+ * Reduce an action's knowledge document to what the agent asked for, returning
66
+ * both the text to read and a machine-readable envelope.
67
+ *
68
+ * By default a large doc is handed back as a digest (request-building sections
69
+ * in full, plus a trailer and a `sections` list naming what was omitted); a
70
+ * small doc is returned whole. `section` selects named section(s), `toc`
71
+ * returns the full table of contents, and `full` (or `section: "all"`) returns
72
+ * the document verbatim. Only the digest and full cases carry the API request
73
+ * guidance, so `wrap` is set for those.
74
+ *
75
+ * @param knowledge - The raw knowledge markdown for the action
76
+ * @param method - The action's HTTP method (echoed into the envelope)
77
+ * @param platform - The kebab-case platform identifier (for the load-more hint)
78
+ * @param actionId - The action ID (for the load-more hint)
79
+ * @param options - `section`/`toc`/`full` select what to return
80
+ */
81
+ export function buildKnowledgeResponse(knowledge, method, platform, actionId, options = {}) {
82
+ const doc = parseSections(knowledge);
83
+ const fullToc = flattenSections(doc.sections).map((s) => ({
84
+ id: s.id,
85
+ heading: s.heading,
86
+ level: s.level,
87
+ chars: s.chars,
88
+ }));
89
+ const sectionNames = parseSectionFlag(options.section);
90
+ const specificSections = sectionNames.filter((n) => n.toLowerCase() !== 'all');
91
+ // `full: true`, or a `section` value that is only "all", asks for the whole doc.
92
+ const wantFull = options.full === true || (sectionNames.length > 0 && specificSections.length === 0);
93
+ // A specific named section takes precedence over `toc` and `full`.
94
+ if (specificSections.length > 0) {
95
+ const picked = selectSections(doc, specificSections);
96
+ if (!picked.ok) {
97
+ // Collapse candidates the same way the digest does: a not-found on a
98
+ // 400-heading doc must not answer with tens of KB of names.
99
+ const toc = collapseSections(picked.candidates);
100
+ const listing = toc.sections
101
+ .map((c) => `${c.heading} [${c.id}] (${c.chars} chars${c.children ? `, +${c.children} nested` : ''})`)
102
+ .join('\n');
103
+ const lead = picked.reason === 'ambiguous'
104
+ ? `Section "${picked.query}" matches several sections; pick one by id or full heading.`
105
+ : `No section named "${picked.query}". Use one of the names below, or full: true.`;
106
+ return {
107
+ wrap: false,
108
+ text: `${lead}\n\n${listing}`,
109
+ structured: {
110
+ error: lead,
111
+ query: picked.query,
112
+ reason: picked.reason,
113
+ sections: toc.sections,
114
+ ...(toc.collapsed ? { sectionsCollapsed: true, sectionCount: picked.candidates.length } : {}),
115
+ },
116
+ };
117
+ }
118
+ return {
119
+ wrap: false,
120
+ text: picked.markdown,
121
+ structured: {
122
+ title: doc.title,
123
+ method,
124
+ truncated: false,
125
+ requested: specificSections,
126
+ resolved: picked.sections.map((s) => ({ id: s.id, heading: s.heading, chars: s.chars })),
127
+ sectionCount: fullToc.length,
128
+ },
129
+ };
130
+ }
131
+ if (options.toc) {
132
+ const listing = fullToc.length
133
+ ? fullToc.map((t) => `${' '.repeat(Math.max(0, t.level - 1))}${t.heading} [${t.id}] (${t.chars} chars)`).join('\n')
134
+ : '(this document has no sections)';
135
+ return {
136
+ wrap: false,
137
+ text: `Table of contents. Load any by name with section: "<id or heading>", or the whole document with full: true.\n\n${listing}`,
138
+ structured: { title: doc.title, method, chars: doc.chars, sections: fullToc },
139
+ };
140
+ }
141
+ // `full`, and any document small enough to keep whole, are returned verbatim
142
+ // rather than reconstructed from the parsed tree, so nothing is reformatted.
143
+ if (wantFull) {
144
+ return { wrap: true, text: knowledge, structured: { title: doc.title, method, truncated: false } };
145
+ }
146
+ const digest = buildDigest(doc);
147
+ if (!digest.truncated) {
148
+ return { wrap: true, text: knowledge, structured: { title: doc.title, method, truncated: false } };
149
+ }
150
+ const toc = omittedSections(digest);
151
+ const text = [renderDigestBanner(digest), digest.markdown, renderDigestNotice(digest, platform, actionId)]
152
+ .filter(Boolean)
153
+ .join('\n\n');
154
+ return {
155
+ wrap: true,
156
+ text,
157
+ structured: {
158
+ title: doc.title,
159
+ method,
160
+ truncated: true,
161
+ omitted: digest.omitted,
162
+ omittedChars: digest.omittedChars,
163
+ sections: toc.sections,
164
+ ...(toc.collapsed ? { sectionsCollapsed: true, sectionCount: digest.sections.length } : {}),
165
+ },
166
+ };
167
+ }
63
168
  /**
64
169
  * Builds action knowledge with API request structure guidance.
65
170
  * @param knowledge - The raw knowledge content for the action
package/build/index.js CHANGED
@@ -11,7 +11,7 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
11
11
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
12
12
  import { ErrorCode, McpError, } from '@modelcontextprotocol/sdk/types.js';
13
13
  import { OneClient } from './client.js';
14
- import { buildActionKnowledgeWithGuidance, buildKnowledgeModeGuidance, filterByPermissions, isMethodAllowed, isActionAllowed, computeConnectionAccess, } from './helpers.js';
14
+ import { buildActionKnowledgeWithGuidance, buildKnowledgeModeGuidance, buildKnowledgeResponse, filterByPermissions, isMethodAllowed, isActionAllowed, computeConnectionAccess, } from './helpers.js';
15
15
  import { listOneIntegrationsToolConfig, searchOnePlatformActionsToolConfig, getOneActionKnowledgeToolConfig, executeOneActionToolConfig } from './schemas.js';
16
16
  const SERVER_NAME = "one-mcp-server";
17
17
  const SERVER_VERSION = "1.0.0";
@@ -212,6 +212,11 @@ async function handleGetActionKnowledge(args) {
212
212
  }
213
213
  }
214
214
  if (ONE_KNOWLEDGE_AGENT) {
215
+ // Knowledge/code-gen mode returns the full document: the appended
216
+ // Integration Code Guide tells the agent to reproduce the complete
217
+ // input and output structure, which the digest would defer. Digesting
218
+ // is for the execute flow below, where the agent only needs to build
219
+ // one request.
215
220
  const details = await oneClient.getActionDetails(actionId);
216
221
  const knowledgeWithGuide = buildKnowledgeModeGuidance(details, args.platform, oneClient.getBaseUrl());
217
222
  return {
@@ -224,14 +229,22 @@ async function handleGetActionKnowledge(args) {
224
229
  };
225
230
  }
226
231
  const { knowledge, method } = await oneClient.getActionKnowledge(actionId);
227
- const knowledgeWithGuidance = buildActionKnowledgeWithGuidance(knowledge, method, oneClient.getBaseUrl(), args.platform, actionId);
232
+ const response = buildKnowledgeResponse(knowledge, method, args.platform, actionId, {
233
+ section: args.section,
234
+ full: args.full,
235
+ toc: args.toc,
236
+ });
237
+ const text = response.wrap
238
+ ? buildActionKnowledgeWithGuidance(response.text, method, oneClient.getBaseUrl(), args.platform, actionId)
239
+ : response.text;
228
240
  return {
229
241
  content: [
230
242
  {
231
243
  type: "text",
232
- text: knowledgeWithGuidance,
244
+ text,
233
245
  },
234
246
  ],
247
+ structuredContent: response.structured,
235
248
  };
236
249
  }
237
250
  catch (error) {
@@ -0,0 +1,73 @@
1
+ import { describe, it } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { buildKnowledgeResponse } from './helpers.js';
4
+ // Pure: string in, response out. No network, no home-directory access.
5
+ const bigSection = (label) => `## ${label}\n` +
6
+ Array.from({ length: 120 }, (_, i) => `- ${label} field ${i}: descriptive text adding length to the section body.`).join('\n');
7
+ const LARGE = [
8
+ '# Send Email',
9
+ '## Method\nPOST',
10
+ '## URL\n/gmail/v1/messages/send',
11
+ '## Headers\n- Authorization: Bearer',
12
+ '## Request Body\n### Required Request Body Fields\n- to: string\n- subject: string',
13
+ bigSection('Response Fields'),
14
+ bigSection('Success Response'),
15
+ bigSection('Optional Request Body Fields'),
16
+ ].join('\n\n');
17
+ const SMALL = '# Tiny\n\n## Method\nGET\n\n## URL\n/x';
18
+ describe('buildKnowledgeResponse', () => {
19
+ it('digests a large doc: request-building sections in full, omitted ones named in the envelope', () => {
20
+ const r = buildKnowledgeResponse(LARGE, 'POST', 'gmail', 'act_1', {});
21
+ assert.equal(r.wrap, true);
22
+ assert.equal(r.structured.truncated, true);
23
+ assert.ok(r.structured.omitted >= 1);
24
+ assert.ok(Array.isArray(r.structured.sections) && r.structured.sections.length >= 1);
25
+ assert.ok(r.text.includes('**Digest.**'));
26
+ assert.ok(r.text.includes('get_one_action_knowledge'));
27
+ // A deferred body is not inlined into the digest text.
28
+ assert.ok(!r.text.includes('Response Fields field 100'));
29
+ // No CLI-flag phrasing leaks into agent-visible text.
30
+ assert.ok(!/--section|one --agent|--full|--toc/.test(r.text));
31
+ });
32
+ it('returns the whole table of contents for toc, with no bodies', () => {
33
+ const r = buildKnowledgeResponse(LARGE, 'POST', 'gmail', 'act_1', { toc: true });
34
+ assert.equal(r.wrap, false);
35
+ assert.ok(r.structured.sections.length >= 5);
36
+ assert.ok(r.text.includes('[response-fields]'));
37
+ assert.ok(!r.text.includes('Response Fields field 100'));
38
+ });
39
+ it('returns a named section with its body and what it resolved to', () => {
40
+ const r = buildKnowledgeResponse(LARGE, 'POST', 'gmail', 'act_1', { section: 'Response Fields' });
41
+ assert.equal(r.wrap, false);
42
+ assert.deepEqual(r.structured.resolved.map((s) => s.heading), ['Response Fields']);
43
+ assert.ok(r.text.includes('Response Fields field 100'));
44
+ });
45
+ it('reports a not-found section with a bounded candidate list', () => {
46
+ const r = buildKnowledgeResponse(LARGE, 'POST', 'gmail', 'act_1', { section: 'nope' });
47
+ assert.equal(r.wrap, false);
48
+ assert.ok(r.structured.error);
49
+ assert.equal(r.structured.reason, 'not-found');
50
+ assert.ok(r.structured.sections.length >= 1);
51
+ assert.ok(r.text.startsWith('No section named "nope"'));
52
+ });
53
+ it('treats section: "all" and full: true as the verbatim whole document', () => {
54
+ for (const opts of [{ section: 'all' }, { full: true }]) {
55
+ const r = buildKnowledgeResponse(LARGE, 'POST', 'gmail', 'act_1', opts);
56
+ assert.equal(r.wrap, true);
57
+ assert.equal(r.text, LARGE);
58
+ assert.equal(r.structured.truncated, false);
59
+ }
60
+ });
61
+ it('returns a small doc verbatim with no digest', () => {
62
+ const r = buildKnowledgeResponse(SMALL, 'GET', 'gmail', 'act_1', {});
63
+ assert.equal(r.wrap, true);
64
+ assert.equal(r.text, SMALL);
65
+ assert.equal(r.structured.truncated, false);
66
+ });
67
+ it('section wins over full when both are set', () => {
68
+ const r = buildKnowledgeResponse(LARGE, 'POST', 'gmail', 'act_1', { section: 'Response Fields', full: true });
69
+ assert.equal(r.wrap, false);
70
+ assert.notEqual(r.text, LARGE);
71
+ assert.ok(r.text.includes('Response Fields field 100'));
72
+ });
73
+ });
@@ -0,0 +1,558 @@
1
+ /**
2
+ * Header-based sectioning for action knowledge markdown.
3
+ *
4
+ * The backend returns one markdown document per action. Half of them are under
5
+ * ~8k chars, but the tail is long (p90 ~17k, max ~370k) and the bulk of every
6
+ * large doc sits in three sections — `Success Response`, `Response Fields`, and
7
+ * `Optional Request Body Fields`. Agents pay per token, so
8
+ * `get_one_action_knowledge` hands back a *digest*: the sections needed to
9
+ * build a correct request in full, plus a table of contents for everything
10
+ * else. The agent asks for the rest by name (`section: "Response Fields"`) or
11
+ * for the whole document (`full: true`) on a follow-up call.
12
+ *
13
+ * Ported from withoneai/cli (`src/lib/knowledge-sections.ts`, PR #197). The
14
+ * parsing/classification/digest engine is identical; only the user-facing
15
+ * "how to load more" strings are adapted for the MCP tool. Keep the engine in
16
+ * sync when the cli module changes.
17
+ *
18
+ * Everything in this module is pure: string in, structure out. No I/O.
19
+ */
20
+ export const DEFAULT_WHOLE_DOC_THRESHOLD = 8_000;
21
+ export const DEFAULT_DIGEST_BUDGET = 8_000;
22
+ export const DEFAULT_SECTION_CAP = 6_000;
23
+ // ---------------------------------------------------------------------------
24
+ // Parsing
25
+ // ---------------------------------------------------------------------------
26
+ const HEADING_RE = /^(#{1,6})[ \t]+(.+?)[ \t]*#*[ \t]*$/;
27
+ const FENCE_RE = /^\s*(```|~~~)/;
28
+ export function slugify(heading) {
29
+ return heading
30
+ .toLowerCase()
31
+ .replace(/`/g, '')
32
+ .replace(/[^a-z0-9]+/g, '-')
33
+ .replace(/^-+|-+$/g, '') || 'section';
34
+ }
35
+ /**
36
+ * Split markdown into a section tree keyed by ATX headings. Headings inside
37
+ * fenced code blocks are ignored. Setext (underlined) headings are not
38
+ * recognised — the backend never emits them.
39
+ */
40
+ export function parseSections(markdown) {
41
+ // Normalise line endings up front: a CRLF document would otherwise leave a
42
+ // trailing `\r` on every line, which defeats the heading regex (`$` will not
43
+ // match before it) so nothing is recognised as a heading. Windows checkouts
44
+ // and some scraped docs carry CRLF, so this is a correctness fix, not just a
45
+ // test convenience.
46
+ const normalized = markdown.replace(/\r\n?/g, '\n');
47
+ const lines = normalized.split('\n');
48
+ const prefaceLines = [];
49
+ const blocks = [];
50
+ let inFence = false;
51
+ let fenceMarker = '';
52
+ for (const line of lines) {
53
+ const fence = FENCE_RE.exec(line);
54
+ if (fence) {
55
+ if (!inFence) {
56
+ inFence = true;
57
+ fenceMarker = fence[1];
58
+ }
59
+ else if (fence[1] === fenceMarker) {
60
+ inFence = false;
61
+ }
62
+ }
63
+ const m = !inFence ? HEADING_RE.exec(line) : null;
64
+ if (m) {
65
+ blocks.push({ level: m[1].length, heading: m[2].trim(), lines: [line] });
66
+ continue;
67
+ }
68
+ if (blocks.length === 0)
69
+ prefaceLines.push(line);
70
+ else
71
+ blocks[blocks.length - 1].lines.push(line);
72
+ }
73
+ const usedIds = new Set();
74
+ const nodes = blocks.map((b) => {
75
+ const base = slugify(b.heading);
76
+ let id = base;
77
+ for (let n = 2; usedIds.has(id); n++)
78
+ id = `${base}-${n}`;
79
+ usedIds.add(id);
80
+ return {
81
+ id,
82
+ heading: b.heading,
83
+ level: b.level,
84
+ text: b.lines.join('\n'),
85
+ chars: 0,
86
+ tier: classifyHeading(b.heading),
87
+ children: [],
88
+ };
89
+ });
90
+ // Build the tree by level. The first heading, when it is an H1, is the title;
91
+ // its children are promoted so the document's H2s are the top-level sections.
92
+ const root = [];
93
+ const stack = [];
94
+ let title = '';
95
+ let titleNode = null;
96
+ for (const node of nodes) {
97
+ while (stack.length && stack[stack.length - 1].level >= node.level)
98
+ stack.pop();
99
+ if (stack.length)
100
+ stack[stack.length - 1].children.push(node);
101
+ else
102
+ root.push(node);
103
+ stack.push(node);
104
+ }
105
+ if (root.length && nodes[0].level === 1 && root[0] === nodes[0]) {
106
+ titleNode = root.shift();
107
+ title = titleNode.heading;
108
+ root.unshift(...titleNode.children);
109
+ titleNode.children = [];
110
+ }
111
+ // Any text under the title heading itself (before the first H2) becomes preface.
112
+ let preface = prefaceLines.join('\n').trim();
113
+ if (titleNode) {
114
+ const own = titleNode.text.split('\n').slice(1).join('\n').trim();
115
+ if (own)
116
+ preface = preface ? `${preface}\n\n${own}` : own;
117
+ }
118
+ const measure = (n) => {
119
+ n.chars = n.text.length + n.children.reduce((sum, c) => sum + measure(c), 0);
120
+ return n.chars;
121
+ };
122
+ root.forEach(measure);
123
+ return { title, preface, sections: root, chars: normalized.length };
124
+ }
125
+ /** Full markdown for a section including its nested children. */
126
+ export function sectionText(section) {
127
+ const parts = [section.text, ...section.children.map(sectionText)];
128
+ return parts.join('\n').replace(/\n+$/, '');
129
+ }
130
+ export function flattenSections(sections) {
131
+ const out = [];
132
+ const walk = (list) => {
133
+ for (const s of list) {
134
+ out.push(s);
135
+ walk(s.children);
136
+ }
137
+ };
138
+ walk(sections);
139
+ return out;
140
+ }
141
+ // ---------------------------------------------------------------------------
142
+ // Classification
143
+ // ---------------------------------------------------------------------------
144
+ /** Lower-case, strip a trailing parenthetical like "(200 OK)" or "(application/json)". */
145
+ export function canonicalHeading(heading) {
146
+ return heading
147
+ .toLowerCase()
148
+ .replace(/\s*\([^)]*\)\s*$/, '')
149
+ .replace(/[`*_]/g, '')
150
+ .replace(/\s+/g, ' ')
151
+ .trim();
152
+ }
153
+ const ESSENTIAL_EXACT = new Set([
154
+ 'prerequisites',
155
+ 'method',
156
+ 'url',
157
+ 'endpoint',
158
+ 'headers',
159
+ 'description',
160
+ 'enforcement rules',
161
+ 'valid examples',
162
+ 'invalid examples',
163
+ 'request body',
164
+ 'sample request',
165
+ 'gotchas',
166
+ 'error handling',
167
+ 'graphql operation',
168
+ 'graphql arguments',
169
+ 'request variables',
170
+ 'variables',
171
+ 'path parameters',
172
+ 'query parameters',
173
+ 'parameters',
174
+ 'arguments',
175
+ ]);
176
+ const ESSENTIAL_PREFIX = ['required '];
177
+ /**
178
+ * Essential sections are what an agent needs to build a correct request.
179
+ * Everything else — response shapes, optional fields, worked examples, prose —
180
+ * is deferred and listed in the table of contents.
181
+ */
182
+ export function classifyHeading(heading) {
183
+ const c = canonicalHeading(heading);
184
+ if (ESSENTIAL_EXACT.has(c))
185
+ return 'essential';
186
+ if (ESSENTIAL_PREFIX.some((p) => c.startsWith(p)))
187
+ return 'essential';
188
+ return 'deferred';
189
+ }
190
+ /** Priority for back-filling deferred sections into spare budget (lower = sooner). */
191
+ function deferredPriority(heading) {
192
+ const c = canonicalHeading(heading);
193
+ if (c.startsWith('optional '))
194
+ return 0;
195
+ if (c === 'behavior' || c === 'behaviour')
196
+ return 1;
197
+ if (c === 'notes')
198
+ return 2;
199
+ if (c === 'example usage' || c.startsWith('example'))
200
+ return 3;
201
+ if (c === 'response fields')
202
+ return 5;
203
+ if (c === 'response' || c.startsWith('success response') || c.startsWith('error response'))
204
+ return 6;
205
+ return 4;
206
+ }
207
+ function truncateText(text, cap, id) {
208
+ const cut = text.slice(0, cap);
209
+ // Back up to a line boundary so we never split a table row or code fence mid-way.
210
+ const nl = cut.lastIndexOf('\n');
211
+ const head = nl > cap * 0.5 ? cut.slice(0, nl) : cut;
212
+ const remaining = text.length - head.length;
213
+ return `${head}\n\n_[truncated — ${remaining.toLocaleString('en-US')} more chars. Load the full section by calling get_one_action_knowledge again with section: "${id}"]_`;
214
+ }
215
+ /**
216
+ * Build the digest: essential sections in full (capped), then deferred
217
+ * sections back-filled in priority order while they fit the budget, then a
218
+ * table of contents for what was left out.
219
+ */
220
+ export function buildDigest(doc, opts = {}) {
221
+ const wholeThreshold = opts.wholeDocThreshold ?? DEFAULT_WHOLE_DOC_THRESHOLD;
222
+ const budget = opts.budget ?? DEFAULT_DIGEST_BUDGET;
223
+ const cap = opts.sectionCap ?? DEFAULT_SECTION_CAP;
224
+ const flat = flattenSections(doc.sections);
225
+ const whole = () => ({
226
+ markdown: renderWhole(doc),
227
+ truncated: false,
228
+ sections: flat.map((s) => summary(s, true)),
229
+ omitted: 0,
230
+ omittedChars: 0,
231
+ });
232
+ // Nothing to section on, or small enough that a second round trip costs more than it saves.
233
+ if (flat.length === 0 || doc.chars <= wholeThreshold)
234
+ return whole();
235
+ const decisions = new Map();
236
+ let used = doc.title ? doc.title.length + 4 : 0;
237
+ used += doc.preface.length;
238
+ // Pass 1: essentials. A deferred child of an essential parent (e.g. Optional
239
+ // fields under a custom action's `Request Body`) stays deferred — unless it is
240
+ // a sub-section (H3+) that itself contains essentials, such as Notion's
241
+ // `Request Body > Option B > Required Request Body Fields`. Then its own text
242
+ // is kept as scaffolding and its children are decided individually. H1/H2
243
+ // deferred nodes (appended schema chunks, companion endpoints) go wholesale.
244
+ const hasEssentialDescendant = (n) => n.children.some((c) => c.tier === 'essential' || hasEssentialDescendant(c));
245
+ const walk = (list) => {
246
+ for (const node of list) {
247
+ if (node.tier === 'essential' || (node.level >= 3 && hasEssentialDescendant(node))) {
248
+ const own = node.text;
249
+ if (own.length > cap) {
250
+ const render = truncateText(own, cap, node.id);
251
+ decisions.set(node.id, { node, included: 'partial', render });
252
+ used += render.length;
253
+ }
254
+ else {
255
+ decisions.set(node.id, { node, included: true, render: own });
256
+ used += own.length;
257
+ }
258
+ walk(node.children);
259
+ }
260
+ else {
261
+ decisions.set(node.id, { node, included: false, render: '' });
262
+ // Children of a deferred node are deferred wholesale.
263
+ for (const c of flattenSections(node.children))
264
+ decisions.set(c.id, { node: c, included: false, render: '' });
265
+ }
266
+ }
267
+ };
268
+ walk(doc.sections);
269
+ // Pass 2: back-fill deferred subtrees whose *parent* is included or top-level,
270
+ // smallest priority first, while they fit whole. Extra H1s (appended schema
271
+ // chunks, companion endpoints) are never back-filled: pulling three random
272
+ // schema types into the digest would mislead more than it helps.
273
+ const candidates = flat
274
+ .filter((s) => decisions.get(s.id)?.included === false)
275
+ .filter((s) => s.level >= 2)
276
+ .filter((s) => {
277
+ const parent = parentOf(doc.sections, s);
278
+ return !parent || decisions.get(parent.id)?.included !== false;
279
+ })
280
+ .sort((a, b) => deferredPriority(a.heading) - deferredPriority(b.heading) || a.chars - b.chars);
281
+ for (const s of candidates) {
282
+ if (used + s.chars > budget)
283
+ continue;
284
+ for (const n of [s, ...flattenSections(s.children)]) {
285
+ decisions.set(n.id, { node: n, included: true, render: n.text });
286
+ }
287
+ used += s.chars;
288
+ }
289
+ // If everything ended up included, the doc fits — return it whole.
290
+ const allIn = flat.every((s) => decisions.get(s.id)?.included === true);
291
+ if (allIn)
292
+ return whole();
293
+ // Render in document order.
294
+ const out = [];
295
+ if (doc.title)
296
+ out.push(`# ${doc.title}`);
297
+ if (doc.preface)
298
+ out.push(doc.preface);
299
+ for (const s of flat) {
300
+ const d = decisions.get(s.id);
301
+ if (d.included !== false)
302
+ out.push(d.render.replace(/\n+$/, ''));
303
+ }
304
+ const sections = flat.map((s) => summary(s, decisions.get(s.id).included));
305
+ // Chars the agent has not seen: whole omitted subtrees (counted once, at the
306
+ // top-most omitted node) plus the cut tail of every partial section.
307
+ const topOmitted = flat.filter((s) => {
308
+ if (decisions.get(s.id).included !== false)
309
+ return false;
310
+ const parent = parentOf(doc.sections, s);
311
+ return !parent || decisions.get(parent.id).included !== false;
312
+ });
313
+ const omittedChars = topOmitted.reduce((sum, s) => sum + s.chars, 0) +
314
+ flat
315
+ .filter((s) => decisions.get(s.id).included === 'partial')
316
+ .reduce((sum, s) => sum + (s.text.length - decisions.get(s.id).render.length), 0);
317
+ return {
318
+ markdown: out.join('\n\n'),
319
+ truncated: true,
320
+ sections,
321
+ omitted: topOmitted.length + flat.filter((s) => decisions.get(s.id).included === 'partial').length,
322
+ omittedChars: Math.max(0, omittedChars),
323
+ };
324
+ }
325
+ function summary(s, included) {
326
+ return { id: s.id, heading: s.heading, level: s.level, chars: s.chars, included };
327
+ }
328
+ function parentOf(roots, target) {
329
+ const find = (list, parent) => {
330
+ for (const n of list) {
331
+ if (n === target)
332
+ return parent;
333
+ const r = find(n.children, n);
334
+ if (r !== undefined)
335
+ return r;
336
+ }
337
+ return undefined;
338
+ };
339
+ return find(roots, null) ?? null;
340
+ }
341
+ export function renderWhole(doc) {
342
+ const out = [];
343
+ if (doc.title)
344
+ out.push(`# ${doc.title}`);
345
+ if (doc.preface)
346
+ out.push(doc.preface);
347
+ for (const s of doc.sections)
348
+ out.push(sectionText(s));
349
+ return out.join('\n\n');
350
+ }
351
+ export const DEFAULT_TOC_MAX_ENTRIES = 60;
352
+ /**
353
+ * Shrink a table of contents for the JSON envelope. Scraped mega-docs can
354
+ * carry 400+ headings (45 KB of metadata — more than the digest itself), so
355
+ * the list is cut to the shallowest heading depth that fits `max` entries.
356
+ * Hidden descendants are counted on their nearest surviving ancestor; the
357
+ * full list is one `--toc` call away.
358
+ */
359
+ export function collapseSections(sections, max = DEFAULT_TOC_MAX_ENTRIES) {
360
+ if (sections.length <= max)
361
+ return { sections, collapsed: false };
362
+ // The action doc proper is everything before the first extra H1; what
363
+ // follows is appended scrape chunks / companion endpoints. The canonical
364
+ // part keeps its H2s (and H3s when they fit); the appendix collapses to
365
+ // its H1s no matter what — those are the entries nobody needs to see.
366
+ const firstH1 = sections.findIndex((s) => s.level === 1);
367
+ const canonEnd = firstH1 === -1 ? sections.length : firstH1;
368
+ for (const depth of [3, 2]) {
369
+ const keep = (s, i) => (i < canonEnd ? s.level <= depth : s.level <= 1);
370
+ const count = sections.filter(keep).length;
371
+ if (count > max && depth !== 2)
372
+ continue;
373
+ const out = [];
374
+ let owner = null;
375
+ sections.forEach((s, i) => {
376
+ if (keep(s, i)) {
377
+ owner = { ...s };
378
+ out.push(owner);
379
+ }
380
+ else if (owner) {
381
+ owner.children = (owner.children ?? 0) + 1;
382
+ }
383
+ });
384
+ return { sections: out, collapsed: true };
385
+ }
386
+ return { sections, collapsed: false };
387
+ }
388
+ /**
389
+ * The table of contents for the JSON envelope: only what the digest left
390
+ * out. Included sections are already visible as headings in the markdown, so
391
+ * listing them again cost ~1.9k chars per response — enough to make the
392
+ * median (small) doc *larger* than before. Collapsed for mega-docs.
393
+ */
394
+ export function omittedSections(digest, max = DEFAULT_TOC_MAX_ENTRIES) {
395
+ return collapseSections(digest.sections.filter((s) => s.included !== true), max);
396
+ }
397
+ /**
398
+ * The trailer appended to a digest so an agent that only reads the markdown
399
+ * (and never looks at the JSON envelope) still knows the document continues.
400
+ */
401
+ export function renderDigestNotice(digest, platform, actionId) {
402
+ if (!digest.truncated)
403
+ return '';
404
+ // List only the top-most omitted sections: in document order the nearest
405
+ // preceding section with a lower level is the parent, and children of an
406
+ // omitted parent are implied by it.
407
+ const topMost = digest.sections.filter((s, idx) => {
408
+ if (s.included === true)
409
+ return false;
410
+ for (let i = idx - 1; i >= 0; i--) {
411
+ const prev = digest.sections[i];
412
+ if (prev.level < s.level)
413
+ return prev.included !== false;
414
+ }
415
+ return true;
416
+ });
417
+ // Extra H1s are appended scrape chunks or companion endpoints, not part of
418
+ // the action doc proper — say so, or "Append Block Children" showing up as
419
+ // omitted reads as if the core doc itself were missing.
420
+ const names = topMost.map((s) => {
421
+ if (s.included === 'partial')
422
+ return `${s.heading} (partial)`;
423
+ if (s.level === 1)
424
+ return `${s.heading} (appendix)`;
425
+ return s.heading;
426
+ });
427
+ // A scraped mega-doc can omit 30+ sections; naming a dozen is enough to
428
+ // orient the agent, the rest are reachable by name on a follow-up call.
429
+ const MAX_NAMES = 12;
430
+ const shown = names.slice(0, MAX_NAMES);
431
+ const rest = names.length - shown.length;
432
+ const nameList = rest > 0 ? `${shown.join(', ')}, and ${rest} more` : shown.join(', ');
433
+ return [
434
+ '---',
435
+ `**This is a digest, not the full document.** ${topMost.length} section${topMost.length === 1 ? '' : 's'} omitted (${digest.omittedChars.toLocaleString('en-US')} chars): ${nameList}.`,
436
+ `Load one or more by calling get_one_action_knowledge again for ${platform} / ${actionId} with section: "${topMost[0]?.heading ?? 'Response'}" (comma-separate several).`,
437
+ `Load the whole document with full: true.`,
438
+ ].join('\n');
439
+ }
440
+ /**
441
+ * One line for the *top* of a digest, so an agent that reads the beginning of
442
+ * the markdown and stops early still learns the document continues.
443
+ */
444
+ export function renderDigestBanner(digest) {
445
+ if (!digest.truncated)
446
+ return '';
447
+ return `> **Digest.** ${digest.omitted} section${digest.omitted === 1 ? '' : 's'} (${digest.omittedChars.toLocaleString('en-US')} chars) omitted: response shapes, reference schemas, or worked examples. The notice at the end names them and shows how to load them.`;
448
+ }
449
+ /** Short names agents are likely to type. Each maps to a predicate over the canonical heading. */
450
+ const ALIASES = {
451
+ response: (c) => c === 'response',
452
+ responses: (c) => c === 'response',
453
+ 'response fields': (c) => c === 'response fields',
454
+ fields: (c) => c === 'response fields',
455
+ optional: (c) => c.startsWith('optional '),
456
+ required: (c) => c.startsWith('required '),
457
+ examples: (c) => c === 'example usage' || c === 'examples',
458
+ example: (c) => c === 'example usage' || c === 'examples',
459
+ errors: (c) => c.startsWith('error response') || c === 'error handling',
460
+ success: (c) => c.startsWith('success response'),
461
+ body: (c) => c === 'request body' || c.endsWith('request body fields'),
462
+ query: (c) => c.endsWith('query parameters'),
463
+ path: (c) => c.endsWith('path parameters'),
464
+ notes: (c) => c === 'notes',
465
+ behavior: (c) => c === 'behavior' || c === 'behaviour',
466
+ gotchas: (c) => c === 'gotchas',
467
+ };
468
+ function normalizeQuery(q) {
469
+ return q.trim().replace(/^["'`]+|["'`]+$/g, '').toLowerCase().replace(/\s+/g, ' ');
470
+ }
471
+ /**
472
+ * Resolve one user-supplied name to sections. Match order: exact id, exact
473
+ * heading, alias, slug prefix / heading prefix, substring. Prefix and
474
+ * substring matches must be unique; aliases may return several.
475
+ */
476
+ export function findSections(doc, query) {
477
+ const flat = flattenSections(doc.sections);
478
+ const q = normalizeQuery(query);
479
+ const qSlug = slugify(q);
480
+ const miss = (reason, candidates) => ({
481
+ ok: false,
482
+ reason,
483
+ query,
484
+ candidates: candidates.map((s) => summary(s, false)),
485
+ });
486
+ if (!q)
487
+ return miss('not-found', flat);
488
+ // Exact id and exact heading together: a doc with two `## Response`
489
+ // headings (id `response` and `response-2`) should return both, not
490
+ // silently the first.
491
+ const exact = flat.filter((s) => s.id === qSlug || canonicalHeading(s.heading) === q || s.heading.toLowerCase() === q);
492
+ if (exact.length)
493
+ return { ok: true, sections: exact };
494
+ const alias = ALIASES[q];
495
+ if (alias) {
496
+ const hits = flat.filter((s) => alias(canonicalHeading(s.heading)));
497
+ if (hits.length)
498
+ return { ok: true, sections: dropNested(hits) };
499
+ }
500
+ const byPrefix = flat.filter((s) => s.id.startsWith(qSlug) || canonicalHeading(s.heading).startsWith(q));
501
+ if (byPrefix.length === 1)
502
+ return { ok: true, sections: byPrefix };
503
+ if (byPrefix.length > 1) {
504
+ // "error response" should match every error response block, not be ambiguous.
505
+ const sameCanon = new Set(byPrefix.map((s) => canonicalHeading(s.heading).replace(/\s*\d.*$/, '')));
506
+ if (sameCanon.size === 1)
507
+ return { ok: true, sections: dropNested(byPrefix) };
508
+ return miss('ambiguous', byPrefix);
509
+ }
510
+ const bySubstring = flat.filter((s) => s.id.includes(qSlug) || canonicalHeading(s.heading).includes(q));
511
+ if (bySubstring.length === 1)
512
+ return { ok: true, sections: bySubstring };
513
+ if (bySubstring.length > 1)
514
+ return miss('ambiguous', bySubstring);
515
+ return miss('not-found', flat);
516
+ }
517
+ /** If a hit's ancestor is also a hit, drop the descendant — its text is already inside the ancestor. */
518
+ function dropNested(hits) {
519
+ const inside = new Set();
520
+ for (const h of hits)
521
+ for (const c of flattenSections(h.children))
522
+ inside.add(c.id);
523
+ return hits.filter((h) => !inside.has(h.id));
524
+ }
525
+ /**
526
+ * Resolve a comma-separated `--section` value. Every name must resolve; the
527
+ * first failure is returned so the agent can correct it.
528
+ */
529
+ export function selectSections(doc, names) {
530
+ const picked = [];
531
+ const seen = new Set();
532
+ for (const name of names) {
533
+ const r = findSections(doc, name);
534
+ if (!r.ok)
535
+ return r;
536
+ for (const s of dropNested(r.sections)) {
537
+ if (!seen.has(s.id)) {
538
+ seen.add(s.id);
539
+ picked.push(s);
540
+ }
541
+ }
542
+ }
543
+ // Document order, and drop anything nested inside another pick.
544
+ const order = new Map(flattenSections(doc.sections).map((s, i) => [s.id, i]));
545
+ const final = dropNested(picked).sort((a, b) => order.get(a.id) - order.get(b.id));
546
+ const parts = [doc.title ? `# ${doc.title}` : '', ...final.map(sectionText)].filter(Boolean);
547
+ return { ok: true, sections: final, markdown: parts.join('\n\n') };
548
+ }
549
+ /** Split a `--section` flag value (repeatable, comma-separated) into names. */
550
+ export function parseSectionFlag(value) {
551
+ if (!value)
552
+ return [];
553
+ const raw = Array.isArray(value) ? value : [value];
554
+ return raw
555
+ .flatMap((v) => v.split(','))
556
+ .map((v) => v.trim())
557
+ .filter(Boolean);
558
+ }
@@ -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.2.3",
3
+ "version": "1.3.0",
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": {