@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
package/README.md
CHANGED
|
@@ -89,6 +89,9 @@ One-click install:
|
|
|
89
89
|
[](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
|
[](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
|
[](https://cursor.com/en/install-mcp?name=one&config=eyJ1cmwiOiAiaHR0cHM6Ly9tY3Aud2l0aG9uZS5haS9tY3AifQ==)
|
|
92
|
+
[](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
|
-
|
|
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
|
|
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
|
|
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
|
+
});
|