@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 CHANGED
@@ -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
@@ -9,6 +9,62 @@
9
9
  import axios from 'axios';
10
10
  import FormData from 'form-data';
11
11
  import { fetchPaginatedData, replacePathVariables } from './helpers.js';
12
+ /** Header names that only the server may set on an outgoing request. */
13
+ const RESERVED_HEADER_PREFIX = 'x-one-';
14
+ /**
15
+ * Drops any caller-supplied header that One itself controls (`x-one-secret`,
16
+ * `x-one-connection-key`, `x-one-action-id`, ...). Comparison is
17
+ * case-insensitive because HTTP header names are.
18
+ */
19
+ export function stripReservedHeaders(headers) {
20
+ if (!headers)
21
+ return {};
22
+ return Object.fromEntries(Object.entries(headers)
23
+ .filter(([name]) => !name.toLowerCase().startsWith(RESERVED_HEADER_PREFIX))
24
+ .map(([name, value]) => [name, String(value)]));
25
+ }
26
+ /**
27
+ * Bounds an upstream error body before it is logged: small payloads pass
28
+ * through, larger ones are truncated to a string, and an unserializable value
29
+ * is replaced with a placeholder. Keeps a huge or odd error response from
30
+ * bloating a single log line.
31
+ */
32
+ function summarizeErrorData(data) {
33
+ if (data === undefined || data === null)
34
+ return undefined;
35
+ let serialized;
36
+ try {
37
+ serialized = typeof data === 'string' ? data : JSON.stringify(data);
38
+ }
39
+ catch {
40
+ return '[unserializable response body]';
41
+ }
42
+ const MAX = 1000;
43
+ return serialized.length <= MAX ? data : `${serialized.slice(0, MAX)}… (${serialized.length} chars, truncated)`;
44
+ }
45
+ /**
46
+ * A log-safe summary of a failed request. Axios errors carry the full request
47
+ * config, including the `x-one-secret` header, so they must never be logged
48
+ * whole. Only the status, the upstream message, the route (query stripped), and
49
+ * a bounded copy of the response body are kept.
50
+ */
51
+ export function describeError(error) {
52
+ if (axios.isAxiosError(error)) {
53
+ const url = error.config?.url;
54
+ return {
55
+ status: error.response?.status,
56
+ statusText: error.response?.statusText,
57
+ method: error.config?.method?.toUpperCase(),
58
+ url: typeof url === 'string' ? url.split('?')[0] : undefined,
59
+ message: error.message,
60
+ data: summarizeErrorData(error.response?.data),
61
+ };
62
+ }
63
+ if (error instanceof Error) {
64
+ return { name: error.name, message: error.message };
65
+ }
66
+ return { error: String(error) };
67
+ }
12
68
  /**
13
69
  * Client for interacting with the One API
14
70
  */
@@ -22,6 +78,11 @@ export class OneClient {
22
78
  connectors = [];
23
79
  isInitialized = false;
24
80
  allowedActionsCache = null;
81
+ // Short-lived cache of fetched action details, keyed by action ID. The
82
+ // knowledge digest and a follow-up section/toc/full call both need the same
83
+ // document, so this lets the follow-up reuse it instead of re-fetching.
84
+ static ACTION_DETAILS_TTL_MS = 5 * 60_000;
85
+ actionDetailsCache = new Map();
25
86
  constructor(optionsOrSecret, baseUrl = "https://api.withone.ai") {
26
87
  if (typeof optionsOrSecret === 'string') {
27
88
  // Legacy constructor: (secret, baseUrl)
@@ -57,11 +118,11 @@ export class OneClient {
57
118
  ]);
58
119
  const [connectionsResult, connectorsResult] = results;
59
120
  if (connectionsResult.status === 'rejected') {
60
- console.error("Failed to fetch connections:", connectionsResult.reason);
121
+ console.error("Failed to fetch connections:", describeError(connectionsResult.reason));
61
122
  this.connections = [];
62
123
  }
63
124
  if (connectorsResult.status === 'rejected') {
64
- console.error("Failed to fetch connectors:", connectorsResult.reason);
125
+ console.error("Failed to fetch connectors:", describeError(connectorsResult.reason));
65
126
  this.connectors = [];
66
127
  }
67
128
  this.isInitialized = true;
@@ -100,7 +161,7 @@ export class OneClient {
100
161
  this.connections = await fetchPaginatedData(url, headers, Object.keys(additionalParams).length > 0 ? additionalParams : undefined);
101
162
  }
102
163
  catch (error) {
103
- console.error("Failed to fetch connections:", error);
164
+ console.error("Failed to fetch connections:", describeError(error));
104
165
  this.connections = [];
105
166
  throw error;
106
167
  }
@@ -115,7 +176,7 @@ export class OneClient {
115
176
  this.connectors = await fetchPaginatedData(url, headers);
116
177
  }
117
178
  catch (error) {
118
- console.error("Failed to fetch connection definitions:", error);
179
+ console.error("Failed to fetch connection definitions:", describeError(error));
119
180
  this.connectors = [];
120
181
  throw error;
121
182
  }
@@ -157,7 +218,7 @@ export class OneClient {
157
218
  return response.data || [];
158
219
  }
159
220
  catch (error) {
160
- console.error("Error searching available actions:", error);
221
+ console.error("Error searching available actions:", describeError(error));
161
222
  if (axios.isAxiosError(error)) {
162
223
  throw new Error(`Failed to search available actions: ${error.response?.status} ${error.response?.statusText}`);
163
224
  }
@@ -174,6 +235,10 @@ export class OneClient {
174
235
  if (!actionId?.trim()) {
175
236
  throw new Error("Action ID is required");
176
237
  }
238
+ const cached = this.actionDetailsCache.get(actionId);
239
+ if (cached && cached.expiresAt > Date.now()) {
240
+ return cached.details;
241
+ }
177
242
  try {
178
243
  const headers = this.generateHeaders();
179
244
  const url = `${this.baseUrl}/v1/knowledge`;
@@ -188,10 +253,14 @@ export class OneClient {
188
253
  if (actions.length === 0) {
189
254
  throw new Error(`Action with ID ${actionId} not found`);
190
255
  }
256
+ this.actionDetailsCache.set(actionId, {
257
+ details: actions[0],
258
+ expiresAt: Date.now() + OneClient.ACTION_DETAILS_TTL_MS
259
+ });
191
260
  return actions[0];
192
261
  }
193
262
  catch (error) {
194
- console.error("Error fetching action details:", error);
263
+ console.error("Error fetching action details:", describeError(error));
195
264
  if (axios.isAxiosError(error)) {
196
265
  throw new Error(`Failed to fetch action details: ${error.response?.status} ${error.response?.statusText}`);
197
266
  }
@@ -219,7 +288,7 @@ export class OneClient {
219
288
  };
220
289
  }
221
290
  catch (error) {
222
- console.error("Error fetching action knowledge:", error);
291
+ console.error("Error fetching action knowledge:", describeError(error));
223
292
  throw error;
224
293
  }
225
294
  }
@@ -254,7 +323,7 @@ export class OneClient {
254
323
  };
255
324
  }
256
325
  catch (error) {
257
- console.error(`Failed to resolve allowed action ${actionId}:`, error);
326
+ console.error(`Failed to resolve allowed action ${actionId}:`, describeError(error));
258
327
  return null;
259
328
  }
260
329
  }));
@@ -273,12 +342,21 @@ export class OneClient {
273
342
  const action = preloadedAction ?? await this.getActionDetails(actionId);
274
343
  const method = action.method;
275
344
  const contentType = isFormData ? 'multipart/form-data' : isFormUrlEncoded ? 'application/x-www-form-urlencoded' : 'application/json';
345
+ // Caller-supplied headers are merged first and may adjust things like
346
+ // Content-Type, but they can never override One's own routing and auth
347
+ // headers. Those are applied last, after the connection and action
348
+ // allowlist checks in index.ts have already passed, so an `x-one-*`
349
+ // header in `headers` cannot redirect the call to another connection,
350
+ // action, or secret.
351
+ // Drop One's default Content-Type (any casing) so the caller's choice,
352
+ // merged in below, is not clobbered when the auth headers are applied last.
353
+ const authHeaders = Object.fromEntries(Object.entries(this.generateHeaders()).filter(([name]) => name.toLowerCase() !== 'content-type'));
276
354
  const requestHeaders = {
277
- ...this.generateHeaders(),
355
+ 'Content-Type': contentType,
356
+ ...stripReservedHeaders(headers),
357
+ ...authHeaders,
278
358
  'x-one-connection-key': connectionKey,
279
359
  'x-one-action-id': action._id,
280
- 'Content-Type': contentType,
281
- ...headers
282
360
  };
283
361
  const finalActionPath = pathVariables
284
362
  ? replacePathVariables(action.path, pathVariables)
@@ -355,7 +433,7 @@ export class OneClient {
355
433
  };
356
434
  }
357
435
  catch (error) {
358
- console.error("Error executing passthrough request:", error);
436
+ console.error("Error executing passthrough request:", describeError(error));
359
437
  if (axios.isAxiosError(error)) {
360
438
  throw new Error(`Failed to execute passthrough request: ${error.response?.status} ${error.response?.statusText}`);
361
439
  }
@@ -0,0 +1,101 @@
1
+ import { describe, it, before, after, beforeEach } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import http from 'node:http';
4
+ import { OneClient, stripReservedHeaders, describeError } from './client.js';
5
+ // A passthrough call must never let the caller override One's own routing/auth
6
+ // headers (the scoping bypass), and a failed call must never log the secret.
7
+ const SECRET = 'sk_test_REALSECRET_abc123';
8
+ const ACTION = { _id: 'conn_mod_def::ALLOWED_ACTION', method: 'GET', path: '/v1/things', tags: [] };
9
+ const exec = (extra) => ({
10
+ platform: 'test',
11
+ actionId: 'conn_mod_def::ALLOWED_ACTION',
12
+ connectionKey: 'live::allowed::default::aaaa',
13
+ ...extra,
14
+ });
15
+ describe("executePassthroughRequest keeps One's headers authoritative", () => {
16
+ let server;
17
+ let base = '';
18
+ let received = [];
19
+ let failBody = null;
20
+ before(async () => {
21
+ server = http.createServer((req, res) => {
22
+ received.push({ headers: req.headers });
23
+ if (failBody !== null) {
24
+ res.writeHead(422, { 'Content-Type': 'application/json' });
25
+ res.end(failBody);
26
+ return;
27
+ }
28
+ res.writeHead(200, { 'Content-Type': 'application/json' });
29
+ res.end(JSON.stringify({ ok: true }));
30
+ });
31
+ await new Promise((resolve) => server.listen(0, '127.0.0.1', () => resolve()));
32
+ base = `http://127.0.0.1:${server.address().port}`;
33
+ });
34
+ after(() => {
35
+ server.close();
36
+ });
37
+ beforeEach(() => {
38
+ received = [];
39
+ failBody = null;
40
+ });
41
+ it('a caller cannot override the x-one-* routing/auth headers', async () => {
42
+ const client = new OneClient(SECRET, base);
43
+ await client.executePassthroughRequest(exec({
44
+ headers: {
45
+ 'x-one-connection-key': 'live::EXCLUDED::default::zzzz',
46
+ 'X-One-Secret': 'sk_live_stolen',
47
+ 'X-ONE-ACTION-ID': 'conn_mod_def::OTHER',
48
+ 'x-custom-trace': 't-1',
49
+ },
50
+ }), ACTION);
51
+ const h = received[0].headers;
52
+ assert.equal(h['x-one-connection-key'], 'live::allowed::default::aaaa');
53
+ assert.equal(h['x-one-secret'], SECRET);
54
+ assert.equal(h['x-one-action-id'], 'conn_mod_def::ALLOWED_ACTION');
55
+ assert.equal(h['x-custom-trace'], 't-1', 'a non-reserved custom header still passes through');
56
+ assert.equal(h['content-type'], 'application/json', 'the default content type is kept');
57
+ });
58
+ it('a caller may still set Content-Type, in any casing', async () => {
59
+ const client = new OneClient(SECRET, base);
60
+ await client.executePassthroughRequest(exec({ headers: { 'content-type': 'text/plain' } }), ACTION);
61
+ assert.equal(received[0].headers['content-type'], 'text/plain');
62
+ });
63
+ it('a failed call throws with the status, never logs the secret, and bounds the body', async () => {
64
+ failBody = JSON.stringify({ error: 'upstream said no', padding: 'x'.repeat(2000) });
65
+ const client = new OneClient(SECRET, base);
66
+ const logged = [];
67
+ const original = console.error;
68
+ console.error = (...args) => {
69
+ logged.push(args.map((a) => (typeof a === 'string' ? a : JSON.stringify(a))).join(' '));
70
+ };
71
+ let message = '';
72
+ try {
73
+ await client.executePassthroughRequest(exec({}), ACTION);
74
+ }
75
+ catch (error) {
76
+ message = error.message;
77
+ }
78
+ finally {
79
+ console.error = original;
80
+ }
81
+ const log = logged.join('\n');
82
+ assert.ok(!log.includes(SECRET), 'the secret never reaches the log');
83
+ assert.ok(log.includes('422') && log.includes('upstream said no'), 'the log still says what failed');
84
+ assert.ok(log.includes('truncated'), 'a huge upstream body is bounded');
85
+ assert.ok(message.includes('422'), 'the thrown tool error carries the status');
86
+ });
87
+ });
88
+ describe('stripReservedHeaders', () => {
89
+ it('drops x-one-* case-insensitively and stringifies values', () => {
90
+ assert.deepEqual(stripReservedHeaders({ 'X-One-Secret': 'a', 'x-one-action-id': 'b', Accept: 'x' }), { Accept: 'x' });
91
+ assert.deepEqual(stripReservedHeaders({ n: 5 }), { n: '5' });
92
+ assert.deepEqual(stripReservedHeaders(undefined), {});
93
+ });
94
+ });
95
+ describe('describeError', () => {
96
+ it('summarizes a plain error to its name and message', () => {
97
+ const described = describeError(new Error('boom'));
98
+ assert.equal(described.name, 'Error');
99
+ assert.equal(described.message, 'boom');
100
+ });
101
+ });
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
+ });