@withone/mcp 1.3.0 → 1.4.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/build/client.js +84 -17
- package/build/client.test.js +109 -0
- package/build/helpers.js +65 -63
- package/build/hidden-actions.test.js +12 -0
- package/build/index.js +23 -32
- package/build/integrations-response.test.js +40 -0
- package/build/knowledge-response.test.js +8 -7
- package/build/path-variables.test.js +26 -0
- package/build/schemas.js +7 -7
- package/package.json +1 -1
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
|
*/
|
|
@@ -18,6 +74,7 @@ export class OneClient {
|
|
|
18
74
|
identity;
|
|
19
75
|
identityType;
|
|
20
76
|
connectionKeys;
|
|
77
|
+
fetchConnectors = true;
|
|
21
78
|
connections = [];
|
|
22
79
|
connectors = [];
|
|
23
80
|
isInitialized = false;
|
|
@@ -46,6 +103,7 @@ export class OneClient {
|
|
|
46
103
|
this.identity = optionsOrSecret.identity;
|
|
47
104
|
this.identityType = optionsOrSecret.identityType;
|
|
48
105
|
this.connectionKeys = optionsOrSecret.connectionKeys;
|
|
106
|
+
this.fetchConnectors = optionsOrSecret.fetchConnectors ?? true;
|
|
49
107
|
}
|
|
50
108
|
}
|
|
51
109
|
/**
|
|
@@ -58,15 +116,15 @@ export class OneClient {
|
|
|
58
116
|
}
|
|
59
117
|
const results = await Promise.allSettled([
|
|
60
118
|
this.fetchConnections(),
|
|
61
|
-
this.fetchConnectionDefinitions(),
|
|
119
|
+
this.fetchConnectors ? this.fetchConnectionDefinitions() : Promise.resolve(),
|
|
62
120
|
]);
|
|
63
121
|
const [connectionsResult, connectorsResult] = results;
|
|
64
122
|
if (connectionsResult.status === 'rejected') {
|
|
65
|
-
console.error("Failed to fetch connections:", connectionsResult.reason);
|
|
123
|
+
console.error("Failed to fetch connections:", describeError(connectionsResult.reason));
|
|
66
124
|
this.connections = [];
|
|
67
125
|
}
|
|
68
126
|
if (connectorsResult.status === 'rejected') {
|
|
69
|
-
console.error("Failed to fetch connectors:", connectorsResult.reason);
|
|
127
|
+
console.error("Failed to fetch connectors:", describeError(connectorsResult.reason));
|
|
70
128
|
this.connectors = [];
|
|
71
129
|
}
|
|
72
130
|
this.isInitialized = true;
|
|
@@ -105,7 +163,7 @@ export class OneClient {
|
|
|
105
163
|
this.connections = await fetchPaginatedData(url, headers, Object.keys(additionalParams).length > 0 ? additionalParams : undefined);
|
|
106
164
|
}
|
|
107
165
|
catch (error) {
|
|
108
|
-
console.error("Failed to fetch connections:", error);
|
|
166
|
+
console.error("Failed to fetch connections:", describeError(error));
|
|
109
167
|
this.connections = [];
|
|
110
168
|
throw error;
|
|
111
169
|
}
|
|
@@ -120,7 +178,7 @@ export class OneClient {
|
|
|
120
178
|
this.connectors = await fetchPaginatedData(url, headers);
|
|
121
179
|
}
|
|
122
180
|
catch (error) {
|
|
123
|
-
console.error("Failed to fetch connection definitions:", error);
|
|
181
|
+
console.error("Failed to fetch connection definitions:", describeError(error));
|
|
124
182
|
this.connectors = [];
|
|
125
183
|
throw error;
|
|
126
184
|
}
|
|
@@ -162,7 +220,7 @@ export class OneClient {
|
|
|
162
220
|
return response.data || [];
|
|
163
221
|
}
|
|
164
222
|
catch (error) {
|
|
165
|
-
console.error("Error searching available actions:", error);
|
|
223
|
+
console.error("Error searching available actions:", describeError(error));
|
|
166
224
|
if (axios.isAxiosError(error)) {
|
|
167
225
|
throw new Error(`Failed to search available actions: ${error.response?.status} ${error.response?.statusText}`);
|
|
168
226
|
}
|
|
@@ -204,7 +262,7 @@ export class OneClient {
|
|
|
204
262
|
return actions[0];
|
|
205
263
|
}
|
|
206
264
|
catch (error) {
|
|
207
|
-
console.error("Error fetching action details:", error);
|
|
265
|
+
console.error("Error fetching action details:", describeError(error));
|
|
208
266
|
if (axios.isAxiosError(error)) {
|
|
209
267
|
throw new Error(`Failed to fetch action details: ${error.response?.status} ${error.response?.statusText}`);
|
|
210
268
|
}
|
|
@@ -232,7 +290,7 @@ export class OneClient {
|
|
|
232
290
|
};
|
|
233
291
|
}
|
|
234
292
|
catch (error) {
|
|
235
|
-
console.error("Error fetching action knowledge:", error);
|
|
293
|
+
console.error("Error fetching action knowledge:", describeError(error));
|
|
236
294
|
throw error;
|
|
237
295
|
}
|
|
238
296
|
}
|
|
@@ -267,7 +325,7 @@ export class OneClient {
|
|
|
267
325
|
};
|
|
268
326
|
}
|
|
269
327
|
catch (error) {
|
|
270
|
-
console.error(`Failed to resolve allowed action ${actionId}:`, error);
|
|
328
|
+
console.error(`Failed to resolve allowed action ${actionId}:`, describeError(error));
|
|
271
329
|
return null;
|
|
272
330
|
}
|
|
273
331
|
}));
|
|
@@ -286,16 +344,25 @@ export class OneClient {
|
|
|
286
344
|
const action = preloadedAction ?? await this.getActionDetails(actionId);
|
|
287
345
|
const method = action.method;
|
|
288
346
|
const contentType = isFormData ? 'multipart/form-data' : isFormUrlEncoded ? 'application/x-www-form-urlencoded' : 'application/json';
|
|
347
|
+
// Caller-supplied headers are merged first and may adjust things like
|
|
348
|
+
// Content-Type, but they can never override One's own routing and auth
|
|
349
|
+
// headers. Those are applied last, after the connection and action
|
|
350
|
+
// allowlist checks in index.ts have already passed, so an `x-one-*`
|
|
351
|
+
// header in `headers` cannot redirect the call to another connection,
|
|
352
|
+
// action, or secret.
|
|
353
|
+
// Drop One's default Content-Type (any casing) so the caller's choice,
|
|
354
|
+
// merged in below, is not clobbered when the auth headers are applied last.
|
|
355
|
+
const authHeaders = Object.fromEntries(Object.entries(this.generateHeaders()).filter(([name]) => name.toLowerCase() !== 'content-type'));
|
|
289
356
|
const requestHeaders = {
|
|
290
|
-
|
|
357
|
+
'Content-Type': contentType,
|
|
358
|
+
...stripReservedHeaders(headers),
|
|
359
|
+
...authHeaders,
|
|
291
360
|
'x-one-connection-key': connectionKey,
|
|
292
361
|
'x-one-action-id': action._id,
|
|
293
|
-
'Content-Type': contentType,
|
|
294
|
-
...headers
|
|
295
362
|
};
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
363
|
+
// Always substitute: a template sent with its {{placeholders}} unfilled
|
|
364
|
+
// only fails upstream, while this names the variables the path expects.
|
|
365
|
+
const finalActionPath = replacePathVariables(action.path, pathVariables ?? {});
|
|
299
366
|
const normalizedPath = finalActionPath.startsWith('/') ? finalActionPath : `/${finalActionPath}`;
|
|
300
367
|
const url = `${this.baseUrl}/v1/passthrough${normalizedPath}`;
|
|
301
368
|
let requestData = data;
|
|
@@ -368,7 +435,7 @@ export class OneClient {
|
|
|
368
435
|
};
|
|
369
436
|
}
|
|
370
437
|
catch (error) {
|
|
371
|
-
console.error("Error executing passthrough request:", error);
|
|
438
|
+
console.error("Error executing passthrough request:", describeError(error));
|
|
372
439
|
if (axios.isAxiosError(error)) {
|
|
373
440
|
throw new Error(`Failed to execute passthrough request: ${error.response?.status} ${error.response?.statusText}`);
|
|
374
441
|
}
|
|
@@ -408,7 +475,7 @@ export class OneClient {
|
|
|
408
475
|
async refresh() {
|
|
409
476
|
await Promise.allSettled([
|
|
410
477
|
this.fetchConnections(),
|
|
411
|
-
this.fetchConnectionDefinitions(),
|
|
478
|
+
this.fetchConnectors ? this.fetchConnectionDefinitions() : Promise.resolve(),
|
|
412
479
|
]);
|
|
413
480
|
}
|
|
414
481
|
}
|
|
@@ -0,0 +1,109 @@
|
|
|
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('names the expected path variables instead of sending an unfilled template', async () => {
|
|
59
|
+
const client = new OneClient(SECRET, base);
|
|
60
|
+
const templated = { ...ACTION, path: '/v1/things/{{thingId}}' };
|
|
61
|
+
await assert.rejects(client.executePassthroughRequest(exec({}), templated), /pass pathVariables with: thingId/);
|
|
62
|
+
assert.equal(received.length, 0, 'nothing is sent upstream');
|
|
63
|
+
await client.executePassthroughRequest(exec({ pathVariables: { thing_id: 7 } }), templated);
|
|
64
|
+
assert.equal(received.length, 1);
|
|
65
|
+
});
|
|
66
|
+
it('a caller may still set Content-Type, in any casing', async () => {
|
|
67
|
+
const client = new OneClient(SECRET, base);
|
|
68
|
+
await client.executePassthroughRequest(exec({ headers: { 'content-type': 'text/plain' } }), ACTION);
|
|
69
|
+
assert.equal(received[0].headers['content-type'], 'text/plain');
|
|
70
|
+
});
|
|
71
|
+
it('a failed call throws with the status, never logs the secret, and bounds the body', async () => {
|
|
72
|
+
failBody = JSON.stringify({ error: 'upstream said no', padding: 'x'.repeat(2000) });
|
|
73
|
+
const client = new OneClient(SECRET, base);
|
|
74
|
+
const logged = [];
|
|
75
|
+
const original = console.error;
|
|
76
|
+
console.error = (...args) => {
|
|
77
|
+
logged.push(args.map((a) => (typeof a === 'string' ? a : JSON.stringify(a))).join(' '));
|
|
78
|
+
};
|
|
79
|
+
let message = '';
|
|
80
|
+
try {
|
|
81
|
+
await client.executePassthroughRequest(exec({}), ACTION);
|
|
82
|
+
}
|
|
83
|
+
catch (error) {
|
|
84
|
+
message = error.message;
|
|
85
|
+
}
|
|
86
|
+
finally {
|
|
87
|
+
console.error = original;
|
|
88
|
+
}
|
|
89
|
+
const log = logged.join('\n');
|
|
90
|
+
assert.ok(!log.includes(SECRET), 'the secret never reaches the log');
|
|
91
|
+
assert.ok(log.includes('422') && log.includes('upstream said no'), 'the log still says what failed');
|
|
92
|
+
assert.ok(log.includes('truncated'), 'a huge upstream body is bounded');
|
|
93
|
+
assert.ok(message.includes('422'), 'the thrown tool error carries the status');
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
describe('stripReservedHeaders', () => {
|
|
97
|
+
it('drops x-one-* case-insensitively and stringifies values', () => {
|
|
98
|
+
assert.deepEqual(stripReservedHeaders({ 'X-One-Secret': 'a', 'x-one-action-id': 'b', Accept: 'x' }), { Accept: 'x' });
|
|
99
|
+
assert.deepEqual(stripReservedHeaders({ n: 5 }), { n: '5' });
|
|
100
|
+
assert.deepEqual(stripReservedHeaders(undefined), {});
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
describe('describeError', () => {
|
|
104
|
+
it('summarizes a plain error to its name and message', () => {
|
|
105
|
+
const described = describeError(new Error('boom'));
|
|
106
|
+
assert.equal(described.name, 'Error');
|
|
107
|
+
assert.equal(described.message, 'boom');
|
|
108
|
+
});
|
|
109
|
+
});
|
package/build/helpers.js
CHANGED
|
@@ -69,8 +69,7 @@ export async function fetchPaginatedData(baseUrl, headers, additionalParams) {
|
|
|
69
69
|
* in full, plus a trailer and a `sections` list naming what was omitted); a
|
|
70
70
|
* small doc is returned whole. `section` selects named section(s), `toc`
|
|
71
71
|
* returns the full table of contents, and `full` (or `section: "all"`) returns
|
|
72
|
-
* the document verbatim.
|
|
73
|
-
* guidance, so `wrap` is set for those.
|
|
72
|
+
* the document verbatim.
|
|
74
73
|
*
|
|
75
74
|
* @param knowledge - The raw knowledge markdown for the action
|
|
76
75
|
* @param method - The action's HTTP method (echoed into the envelope)
|
|
@@ -104,7 +103,6 @@ export function buildKnowledgeResponse(knowledge, method, platform, actionId, op
|
|
|
104
103
|
? `Section "${picked.query}" matches several sections; pick one by id or full heading.`
|
|
105
104
|
: `No section named "${picked.query}". Use one of the names below, or full: true.`;
|
|
106
105
|
return {
|
|
107
|
-
wrap: false,
|
|
108
106
|
text: `${lead}\n\n${listing}`,
|
|
109
107
|
structured: {
|
|
110
108
|
error: lead,
|
|
@@ -116,7 +114,6 @@ export function buildKnowledgeResponse(knowledge, method, platform, actionId, op
|
|
|
116
114
|
};
|
|
117
115
|
}
|
|
118
116
|
return {
|
|
119
|
-
wrap: false,
|
|
120
117
|
text: picked.markdown,
|
|
121
118
|
structured: {
|
|
122
119
|
title: doc.title,
|
|
@@ -133,7 +130,6 @@ export function buildKnowledgeResponse(knowledge, method, platform, actionId, op
|
|
|
133
130
|
? fullToc.map((t) => `${' '.repeat(Math.max(0, t.level - 1))}${t.heading} [${t.id}] (${t.chars} chars)`).join('\n')
|
|
134
131
|
: '(this document has no sections)';
|
|
135
132
|
return {
|
|
136
|
-
wrap: false,
|
|
137
133
|
text: `Table of contents. Load any by name with section: "<id or heading>", or the whole document with full: true.\n\n${listing}`,
|
|
138
134
|
structured: { title: doc.title, method, chars: doc.chars, sections: fullToc },
|
|
139
135
|
};
|
|
@@ -141,18 +137,17 @@ export function buildKnowledgeResponse(knowledge, method, platform, actionId, op
|
|
|
141
137
|
// `full`, and any document small enough to keep whole, are returned verbatim
|
|
142
138
|
// rather than reconstructed from the parsed tree, so nothing is reformatted.
|
|
143
139
|
if (wantFull) {
|
|
144
|
-
return {
|
|
140
|
+
return { text: knowledge, structured: { title: doc.title, method, truncated: false } };
|
|
145
141
|
}
|
|
146
142
|
const digest = buildDigest(doc);
|
|
147
143
|
if (!digest.truncated) {
|
|
148
|
-
return {
|
|
144
|
+
return { text: knowledge, structured: { title: doc.title, method, truncated: false } };
|
|
149
145
|
}
|
|
150
146
|
const toc = omittedSections(digest);
|
|
151
147
|
const text = [renderDigestBanner(digest), digest.markdown, renderDigestNotice(digest, platform, actionId)]
|
|
152
148
|
.filter(Boolean)
|
|
153
149
|
.join('\n\n');
|
|
154
150
|
return {
|
|
155
|
-
wrap: true,
|
|
156
151
|
text,
|
|
157
152
|
structured: {
|
|
158
153
|
title: doc.title,
|
|
@@ -165,42 +160,6 @@ export function buildKnowledgeResponse(knowledge, method, platform, actionId, op
|
|
|
165
160
|
},
|
|
166
161
|
};
|
|
167
162
|
}
|
|
168
|
-
/**
|
|
169
|
-
* Builds action knowledge with API request structure guidance.
|
|
170
|
-
* @param knowledge - The raw knowledge content for the action
|
|
171
|
-
* @param method - The HTTP method for the action
|
|
172
|
-
* @param baseUrl - The base URL for One API
|
|
173
|
-
* @param platform - The platform name (used for connection key)
|
|
174
|
-
* @param actionId - The action ID
|
|
175
|
-
* @returns Complete formatted knowledge with API guidance
|
|
176
|
-
*/
|
|
177
|
-
export function buildActionKnowledgeWithGuidance(knowledge, method, baseUrl, platform, actionId) {
|
|
178
|
-
const cleanBaseUrl = baseUrl.replace(/\/$/, '');
|
|
179
|
-
return `${knowledge}
|
|
180
|
-
|
|
181
|
-
API REQUEST STRUCTURE
|
|
182
|
-
======================
|
|
183
|
-
URL: ${cleanBaseUrl}/v1/passthrough/{{PATH}}
|
|
184
|
-
|
|
185
|
-
IMPORTANT: When constructing the URL, only include the API endpoint path after the base URL.
|
|
186
|
-
Do NOT include the full third-party API URL.
|
|
187
|
-
|
|
188
|
-
Examples:
|
|
189
|
-
✅ Correct: ${cleanBaseUrl}/v1/passthrough/crm/v3/objects/contacts/search
|
|
190
|
-
❌ Incorrect: ${cleanBaseUrl}/v1/passthrough/https://api.hubapi.com/crm/v3/objects/contacts/search
|
|
191
|
-
|
|
192
|
-
METHOD: ${method}
|
|
193
|
-
|
|
194
|
-
HEADERS:
|
|
195
|
-
- x-one-secret: {{process.env.ONE_SECRET}}
|
|
196
|
-
- x-one-connection-key: {{process.env.ONE_${platform.toUpperCase()}_CONNECTION_KEY}}
|
|
197
|
-
- x-one-action-id: ${actionId}
|
|
198
|
-
- ... (other headers)
|
|
199
|
-
|
|
200
|
-
BODY: {{BODY}}
|
|
201
|
-
|
|
202
|
-
QUERY PARAMS: {{QUERY_PARAMS}}`;
|
|
203
|
-
}
|
|
204
163
|
/**
|
|
205
164
|
* Uppercases a platform id into an env-var segment, collapsing every
|
|
206
165
|
* non-alphanumeric run to a single underscore ("ship-station" -> "SHIP_STATION").
|
|
@@ -301,37 +260,38 @@ const response = await fetch("${apiBase}/v1/passthrough${path}", {
|
|
|
301
260
|
});
|
|
302
261
|
\`\`\``;
|
|
303
262
|
}
|
|
263
|
+
/** Folds a path variable name for loose matching: `ticket_id`, `ticketId`, `TICKET-ID` all fold to `ticketid`. */
|
|
264
|
+
const foldVariableName = (name) => name.toLowerCase().replace(/[\s_-]/g, '');
|
|
304
265
|
/**
|
|
305
266
|
* Replaces path variables in a template string with actual values.
|
|
306
|
-
* Path variables can be in either format: {variableName} or {{variableName}}
|
|
267
|
+
* Path variables can be in either format: {variableName} or {{variableName}}.
|
|
268
|
+
* A variable is looked up by its exact name first, then by name ignoring case,
|
|
269
|
+
* underscores, hyphens and spaces, because knowledge docs and path templates
|
|
270
|
+
* do not always spell a variable the same way (`ticket_id` vs `{{ticketId}}`).
|
|
307
271
|
* @param path - The template string containing path variables in {variableName} or {{variableName}} format
|
|
308
272
|
* @param variables - Object containing variable names as keys and their replacement values
|
|
309
273
|
* @returns The path string with all variables replaced by their encoded values
|
|
310
|
-
* @throws Error if any required variable is missing, null, undefined, or empty string
|
|
274
|
+
* @throws Error if any required variable is missing, null, undefined, or empty string, naming every variable the path expects
|
|
311
275
|
*/
|
|
312
276
|
export function replacePathVariables(path, variables) {
|
|
313
277
|
if (!path)
|
|
314
278
|
return path;
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
});
|
|
325
|
-
// Then, replace single bracket variables {variableName}
|
|
326
|
-
result = result.replace(/\{([^}]+)\}/g, (match, variable) => {
|
|
327
|
-
const trimmedVariable = variable.trim();
|
|
328
|
-
const value = variables[trimmedVariable];
|
|
279
|
+
const expected = [...path.matchAll(/\{\{([^}]+)\}\}|\{([^{}]+)\}/g)].map((m) => (m[1] ?? m[2]).trim());
|
|
280
|
+
const folded = new Map();
|
|
281
|
+
for (const [key, value] of Object.entries(variables ?? {})) {
|
|
282
|
+
if (!folded.has(foldVariableName(key)))
|
|
283
|
+
folded.set(foldVariableName(key), value);
|
|
284
|
+
}
|
|
285
|
+
const lookup = (variable) => {
|
|
286
|
+
const name = variable.trim();
|
|
287
|
+
const value = name in (variables ?? {}) ? variables[name] : folded.get(foldVariableName(name));
|
|
329
288
|
if (value === undefined || value === null || value === '') {
|
|
330
|
-
throw new Error(`Missing value for path variable: ${
|
|
289
|
+
throw new Error(`Missing value for path variable: ${name}. This action's path is ${path}; pass pathVariables with: ${[...new Set(expected)].join(', ')}.`);
|
|
331
290
|
}
|
|
332
291
|
return encodeURIComponent(value.toString());
|
|
333
|
-
}
|
|
334
|
-
|
|
292
|
+
};
|
|
293
|
+
// Double brackets first, so {{name}} is not read as {name} wrapped in braces.
|
|
294
|
+
return path.replace(/\{\{([^}]+)\}\}/g, (_match, variable) => lookup(variable)).replace(/\{([^}]+)\}/g, (_match, variable) => lookup(variable));
|
|
335
295
|
}
|
|
336
296
|
const PERMISSION_METHODS = {
|
|
337
297
|
read: ["GET"],
|
|
@@ -375,3 +335,45 @@ export function computeConnectionAccess(platform, permissions, allowedActionIds,
|
|
|
375
335
|
}
|
|
376
336
|
return { policy: "full" };
|
|
377
337
|
}
|
|
338
|
+
/**
|
|
339
|
+
* Builds the `list_one_integrations` response: the user's active connections,
|
|
340
|
+
* each with the access the current config confers. Access is only defined per
|
|
341
|
+
* connection, so unconnected platforms are left out unless `available` is
|
|
342
|
+
* passed (knowledge/code-gen mode, where an agent may write code against a
|
|
343
|
+
* platform the user has not connected yet and needs its kebab-case name).
|
|
344
|
+
* @param connections - The user's connections
|
|
345
|
+
* @param access - The access config: permission level, action allowlist, and
|
|
346
|
+
* the allowlist already resolved to action metadata
|
|
347
|
+
* @param available - Connection definitions to list as available platforms
|
|
348
|
+
*/
|
|
349
|
+
export function buildIntegrationsResponse(connections, access, available) {
|
|
350
|
+
const active = connections.filter((conn) => conn.active);
|
|
351
|
+
const response = {
|
|
352
|
+
connections: active.map((conn) => ({
|
|
353
|
+
platform: conn.platform,
|
|
354
|
+
key: conn.key,
|
|
355
|
+
tags: conn.tags ?? [],
|
|
356
|
+
access: computeConnectionAccess(conn.platform, access.permissions, access.actionIds, access.resolvedAllowed),
|
|
357
|
+
})),
|
|
358
|
+
summary: { connectedCount: active.length },
|
|
359
|
+
};
|
|
360
|
+
if (available) {
|
|
361
|
+
const platforms = available.filter((def) => def.active && !def.deprecated);
|
|
362
|
+
response.availablePlatforms = platforms.map((def) => ({
|
|
363
|
+
platform: def.platform,
|
|
364
|
+
name: def.name,
|
|
365
|
+
category: def.category,
|
|
366
|
+
}));
|
|
367
|
+
response.summary.availableCount = platforms.length;
|
|
368
|
+
}
|
|
369
|
+
return response;
|
|
370
|
+
}
|
|
371
|
+
/** Back office tag for actions agents must not use (a `custom` action covers them). */
|
|
372
|
+
export const HIDDEN_FROM_AGENTS_TAG = "hidden:agents";
|
|
373
|
+
/**
|
|
374
|
+
* Whether an action is hidden from executing agents. Knowledge/code-gen mode
|
|
375
|
+
* still sees these: writing integration code may target the raw endpoint.
|
|
376
|
+
*/
|
|
377
|
+
export function isHiddenFromAgents(tags) {
|
|
378
|
+
return (tags ?? []).includes(HIDDEN_FROM_AGENTS_TAG);
|
|
379
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { describe, it } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { isHiddenFromAgents } from './helpers.js';
|
|
4
|
+
describe('isHiddenFromAgents', () => {
|
|
5
|
+
it('flags only actions tagged hidden:agents', () => {
|
|
6
|
+
assert.equal(isHiddenFromAgents(['hidden:agents']), true);
|
|
7
|
+
assert.equal(isHiddenFromAgents(['custom', 'hidden:agents']), true);
|
|
8
|
+
assert.equal(isHiddenFromAgents(['custom']), false);
|
|
9
|
+
assert.equal(isHiddenFromAgents([]), false);
|
|
10
|
+
assert.equal(isHiddenFromAgents(undefined), false);
|
|
11
|
+
});
|
|
12
|
+
});
|
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 {
|
|
14
|
+
import { buildKnowledgeModeGuidance, buildKnowledgeResponse, filterByPermissions, isMethodAllowed, isActionAllowed, buildIntegrationsResponse, isHiddenFromAgents, } 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";
|
|
@@ -41,6 +41,8 @@ const oneClient = new OneClient({
|
|
|
41
41
|
identity: ONE_IDENTITY,
|
|
42
42
|
identityType: ONE_IDENTITY_TYPE,
|
|
43
43
|
connectionKeys: ONE_CONNECTION_KEYS,
|
|
44
|
+
// The platform catalog is only listed in knowledge/code-gen mode.
|
|
45
|
+
fetchConnectors: ONE_KNOWLEDGE_AGENT,
|
|
44
46
|
});
|
|
45
47
|
let oneInitialized = false;
|
|
46
48
|
let initializationPromise = null;
|
|
@@ -88,37 +90,18 @@ if (!ONE_KNOWLEDGE_AGENT) {
|
|
|
88
90
|
}
|
|
89
91
|
async function handleGetIntegrations(args) {
|
|
90
92
|
try {
|
|
91
|
-
const connectedIntegrations = oneClient.getUserConnections();
|
|
92
|
-
const availableIntegrations = oneClient.getAvailableConnectors();
|
|
93
|
-
const activeConnections = connectedIntegrations.filter(conn => conn.active);
|
|
94
|
-
let activePlatforms = availableIntegrations.filter(def => def.active && !def.deprecated);
|
|
95
|
-
// When connection keys are scoped, only show platforms that match active connections
|
|
96
|
-
if (!ONE_CONNECTION_KEYS.includes("*")) {
|
|
97
|
-
const connectedPlatforms = new Set(activeConnections.map(conn => conn.platform));
|
|
98
|
-
activePlatforms = activePlatforms.filter(def => connectedPlatforms.has(def.platform));
|
|
99
|
-
}
|
|
100
93
|
// Resolve the action allowlist to per-platform metadata (method-filtered by
|
|
101
94
|
// the permission level) once, so each connection can report the exact
|
|
102
95
|
// actions it may run. Only needed when an action allowlist is set.
|
|
103
96
|
const resolvedAllowed = (await oneClient.resolveAllowedActions(ONE_ACTION_IDS))
|
|
104
97
|
.filter(action => isMethodAllowed(action.method, ONE_PERMISSIONS));
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
availablePlatforms: activePlatforms.map(def => ({
|
|
113
|
-
platform: def.platform,
|
|
114
|
-
name: def.name,
|
|
115
|
-
category: def.category
|
|
116
|
-
})),
|
|
117
|
-
summary: {
|
|
118
|
-
connectedCount: activeConnections.length,
|
|
119
|
-
availableCount: activePlatforms.length
|
|
120
|
-
}
|
|
121
|
-
};
|
|
98
|
+
// Execute mode lists only connections: every connection carries its
|
|
99
|
+
// access, and the full catalog (800+ platforms) would sit in the agent's
|
|
100
|
+
// context for the rest of the session. Knowledge/code-gen mode also lists
|
|
101
|
+
// available platforms so code can target one that is not connected yet,
|
|
102
|
+
// unless connection keys are scoped.
|
|
103
|
+
const includeAvailable = ONE_KNOWLEDGE_AGENT && ONE_CONNECTION_KEYS.includes("*");
|
|
104
|
+
const structuredResponse = buildIntegrationsResponse(oneClient.getUserConnections(), { permissions: ONE_PERMISSIONS, actionIds: ONE_ACTION_IDS, resolvedAllowed }, includeAvailable ? oneClient.getAvailableConnectors() : undefined);
|
|
122
105
|
return {
|
|
123
106
|
content: [
|
|
124
107
|
{
|
|
@@ -136,7 +119,9 @@ async function handleGetIntegrations(args) {
|
|
|
136
119
|
async function handleSearchPlatformActions(args) {
|
|
137
120
|
try {
|
|
138
121
|
// Force knowledge mode when ONE_KNOWLEDGE_AGENT is enabled
|
|
139
|
-
|
|
122
|
+
// With execute available, default to the execute catalog: it hides actions
|
|
123
|
+
// tagged hidden:agents and offers the custom actions that replace them.
|
|
124
|
+
const agentType = ONE_KNOWLEDGE_AGENT ? "knowledge" : (args.agentType ?? "execute");
|
|
140
125
|
let actions = await oneClient.searchAvailableActions(args.platform, args.query, agentType);
|
|
141
126
|
// Apply permission-level filtering
|
|
142
127
|
actions = filterByPermissions(actions, ONE_PERMISSIONS);
|
|
@@ -228,20 +213,23 @@ async function handleGetActionKnowledge(args) {
|
|
|
228
213
|
],
|
|
229
214
|
};
|
|
230
215
|
}
|
|
216
|
+
if (isHiddenFromAgents((await oneClient.getActionDetails(actionId)).tags)) {
|
|
217
|
+
throw new McpError(ErrorCode.InvalidRequest, `Action "${actionId}" is hidden from agents; search for the task again to find the action to use instead`);
|
|
218
|
+
}
|
|
231
219
|
const { knowledge, method } = await oneClient.getActionKnowledge(actionId);
|
|
232
220
|
const response = buildKnowledgeResponse(knowledge, method, args.platform, actionId, {
|
|
233
221
|
section: args.section,
|
|
234
222
|
full: args.full,
|
|
235
223
|
toc: args.toc,
|
|
236
224
|
});
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
225
|
+
// Execute mode returns the documentation alone: execute_one_action builds
|
|
226
|
+
// the passthrough request itself, so the raw-HTTP guidance code-gen mode
|
|
227
|
+
// appends (base URL, x-one-* headers, env var names) would only add tokens.
|
|
240
228
|
return {
|
|
241
229
|
content: [
|
|
242
230
|
{
|
|
243
231
|
type: "text",
|
|
244
|
-
text,
|
|
232
|
+
text: response.text,
|
|
245
233
|
},
|
|
246
234
|
],
|
|
247
235
|
structuredContent: response.structured,
|
|
@@ -263,6 +251,9 @@ async function handleExecuteOneAction(args) {
|
|
|
263
251
|
}
|
|
264
252
|
}
|
|
265
253
|
const actionDetails = await oneClient.getActionDetails(args.actionId);
|
|
254
|
+
if (isHiddenFromAgents(actionDetails.tags)) {
|
|
255
|
+
throw new McpError(ErrorCode.InvalidRequest, `Action "${args.actionId}" is hidden from agents; search for the task again to find the action to use instead`);
|
|
256
|
+
}
|
|
266
257
|
if (!isMethodAllowed(actionDetails.method, ONE_PERMISSIONS)) {
|
|
267
258
|
throw new McpError(ErrorCode.InvalidRequest, `Method "${actionDetails.method}" is not allowed under "${ONE_PERMISSIONS}" permission level`);
|
|
268
259
|
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { describe, it } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { buildIntegrationsResponse } from './helpers.js';
|
|
4
|
+
const conn = (platform, active = true) => ({ key: `live::${platform}::default::k`, platform, tags: [], active });
|
|
5
|
+
const def = (platform, extra = {}) => ({ platform, name: platform.toUpperCase(), category: 'Test', active: true, deprecated: false, ...extra });
|
|
6
|
+
const FULL = { permissions: 'admin', actionIds: ['*'], resolvedAllowed: [] };
|
|
7
|
+
describe('buildIntegrationsResponse', () => {
|
|
8
|
+
it('lists only active connections, without the platform catalog', () => {
|
|
9
|
+
const r = buildIntegrationsResponse([conn('gmail'), conn('slack', false)], FULL);
|
|
10
|
+
assert.deepEqual(r.connections.map((c) => c.platform), ['gmail']);
|
|
11
|
+
assert.equal(r.availablePlatforms, undefined);
|
|
12
|
+
assert.deepEqual(r.summary, { connectedCount: 1 });
|
|
13
|
+
});
|
|
14
|
+
it('keeps the per-connection access', () => {
|
|
15
|
+
const r = buildIntegrationsResponse([conn('gmail')], { permissions: 'read', actionIds: ['*'], resolvedAllowed: [] });
|
|
16
|
+
assert.deepEqual(r.connections[0].access, { policy: 'methods', methods: ['GET'] });
|
|
17
|
+
const scoped = buildIntegrationsResponse([conn('gmail')], {
|
|
18
|
+
permissions: 'admin',
|
|
19
|
+
actionIds: ['act_1'],
|
|
20
|
+
resolvedAllowed: [{ actionId: 'act_1', title: 'Send Email', method: 'POST', platform: 'gmail' }],
|
|
21
|
+
});
|
|
22
|
+
assert.deepEqual(scoped.connections[0].access, {
|
|
23
|
+
policy: 'actions',
|
|
24
|
+
actions: [{ actionId: 'act_1', title: 'Send Email', method: 'POST' }],
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
it('adds active, non-deprecated platforms when a catalog is passed (code-gen mode)', () => {
|
|
28
|
+
const r = buildIntegrationsResponse([conn('gmail')], FULL, [
|
|
29
|
+
def('gmail'),
|
|
30
|
+
def('hubspot'),
|
|
31
|
+
def('old-crm', { deprecated: true }),
|
|
32
|
+
def('beta-thing', { active: false }),
|
|
33
|
+
]);
|
|
34
|
+
assert.deepEqual(r.availablePlatforms, [
|
|
35
|
+
{ platform: 'gmail', name: 'GMAIL', category: 'Test' },
|
|
36
|
+
{ platform: 'hubspot', name: 'HUBSPOT', category: 'Test' },
|
|
37
|
+
]);
|
|
38
|
+
assert.deepEqual(r.summary, { connectedCount: 1, availableCount: 2 });
|
|
39
|
+
});
|
|
40
|
+
});
|
|
@@ -18,7 +18,6 @@ const SMALL = '# Tiny\n\n## Method\nGET\n\n## URL\n/x';
|
|
|
18
18
|
describe('buildKnowledgeResponse', () => {
|
|
19
19
|
it('digests a large doc: request-building sections in full, omitted ones named in the envelope', () => {
|
|
20
20
|
const r = buildKnowledgeResponse(LARGE, 'POST', 'gmail', 'act_1', {});
|
|
21
|
-
assert.equal(r.wrap, true);
|
|
22
21
|
assert.equal(r.structured.truncated, true);
|
|
23
22
|
assert.ok(r.structured.omitted >= 1);
|
|
24
23
|
assert.ok(Array.isArray(r.structured.sections) && r.structured.sections.length >= 1);
|
|
@@ -31,20 +30,17 @@ describe('buildKnowledgeResponse', () => {
|
|
|
31
30
|
});
|
|
32
31
|
it('returns the whole table of contents for toc, with no bodies', () => {
|
|
33
32
|
const r = buildKnowledgeResponse(LARGE, 'POST', 'gmail', 'act_1', { toc: true });
|
|
34
|
-
assert.equal(r.wrap, false);
|
|
35
33
|
assert.ok(r.structured.sections.length >= 5);
|
|
36
34
|
assert.ok(r.text.includes('[response-fields]'));
|
|
37
35
|
assert.ok(!r.text.includes('Response Fields field 100'));
|
|
38
36
|
});
|
|
39
37
|
it('returns a named section with its body and what it resolved to', () => {
|
|
40
38
|
const r = buildKnowledgeResponse(LARGE, 'POST', 'gmail', 'act_1', { section: 'Response Fields' });
|
|
41
|
-
assert.equal(r.wrap, false);
|
|
42
39
|
assert.deepEqual(r.structured.resolved.map((s) => s.heading), ['Response Fields']);
|
|
43
40
|
assert.ok(r.text.includes('Response Fields field 100'));
|
|
44
41
|
});
|
|
45
42
|
it('reports a not-found section with a bounded candidate list', () => {
|
|
46
43
|
const r = buildKnowledgeResponse(LARGE, 'POST', 'gmail', 'act_1', { section: 'nope' });
|
|
47
|
-
assert.equal(r.wrap, false);
|
|
48
44
|
assert.ok(r.structured.error);
|
|
49
45
|
assert.equal(r.structured.reason, 'not-found');
|
|
50
46
|
assert.ok(r.structured.sections.length >= 1);
|
|
@@ -53,21 +49,26 @@ describe('buildKnowledgeResponse', () => {
|
|
|
53
49
|
it('treats section: "all" and full: true as the verbatim whole document', () => {
|
|
54
50
|
for (const opts of [{ section: 'all' }, { full: true }]) {
|
|
55
51
|
const r = buildKnowledgeResponse(LARGE, 'POST', 'gmail', 'act_1', opts);
|
|
56
|
-
assert.equal(r.wrap, true);
|
|
57
52
|
assert.equal(r.text, LARGE);
|
|
58
53
|
assert.equal(r.structured.truncated, false);
|
|
59
54
|
}
|
|
60
55
|
});
|
|
61
56
|
it('returns a small doc verbatim with no digest', () => {
|
|
62
57
|
const r = buildKnowledgeResponse(SMALL, 'GET', 'gmail', 'act_1', {});
|
|
63
|
-
assert.equal(r.wrap, true);
|
|
64
58
|
assert.equal(r.text, SMALL);
|
|
65
59
|
assert.equal(r.structured.truncated, false);
|
|
66
60
|
});
|
|
67
61
|
it('section wins over full when both are set', () => {
|
|
68
62
|
const r = buildKnowledgeResponse(LARGE, 'POST', 'gmail', 'act_1', { section: 'Response Fields', full: true });
|
|
69
|
-
assert.equal(r.wrap, false);
|
|
70
63
|
assert.notEqual(r.text, LARGE);
|
|
71
64
|
assert.ok(r.text.includes('Response Fields field 100'));
|
|
72
65
|
});
|
|
73
66
|
});
|
|
67
|
+
describe('execute-mode knowledge text', () => {
|
|
68
|
+
it('carries no raw-HTTP request guidance', () => {
|
|
69
|
+
for (const options of [{}, { full: true }, { section: 'Method' }, { toc: true }]) {
|
|
70
|
+
const r = buildKnowledgeResponse(LARGE, 'POST', 'gmail', 'act_1', options);
|
|
71
|
+
assert.doesNotMatch(r.text, /API REQUEST STRUCTURE|x-one-secret|\/v1\/passthrough/);
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
});
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { describe, it } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { replacePathVariables } from './helpers.js';
|
|
4
|
+
describe('replacePathVariables', () => {
|
|
5
|
+
it('fills exact names in both bracket styles', () => {
|
|
6
|
+
assert.equal(replacePathVariables('/tickets/{{ticketId}}/comments/{id}', { ticketId: 42, id: 'c 1' }), '/tickets/42/comments/c%201');
|
|
7
|
+
});
|
|
8
|
+
it('matches a variable regardless of case, underscores and hyphens', () => {
|
|
9
|
+
for (const key of ['ticket_id', 'TICKET_ID', 'ticket-id', 'TicketId']) {
|
|
10
|
+
assert.equal(replacePathVariables('/api/v2/tickets/{{ticketId}}', { [key]: 48213 }), '/api/v2/tickets/48213', key);
|
|
11
|
+
}
|
|
12
|
+
assert.equal(replacePathVariables('/repos/{owner}/{repo}/pulls/{{pullNumber}}', { owner: 'acme', repo: 'web', pull_number: 517 }), '/repos/acme/web/pulls/517');
|
|
13
|
+
});
|
|
14
|
+
it('prefers the exact name over a loose match', () => {
|
|
15
|
+
assert.equal(replacePathVariables('/x/{{ticketId}}', { ticket_id: 'loose', ticketId: 'exact' }), '/x/exact');
|
|
16
|
+
});
|
|
17
|
+
it('names every expected variable when one is missing', () => {
|
|
18
|
+
assert.throws(() => replacePathVariables('/repos/{{owner}}/{{repo}}/pulls/{{pullNumber}}', { owner: 'acme', repo: 'web' }), /Missing value for path variable: pullNumber\..*pass pathVariables with: owner, repo, pullNumber\./);
|
|
19
|
+
});
|
|
20
|
+
it('leaves a path without variables untouched, even with no variables given', () => {
|
|
21
|
+
assert.equal(replacePathVariables('/v1/gmail/send-email', {}), '/v1/gmail/send-email');
|
|
22
|
+
});
|
|
23
|
+
it('rejects empty values', () => {
|
|
24
|
+
assert.throws(() => replacePathVariables('/x/{{id}}', { id: '' }), /Missing value for path variable: id/);
|
|
25
|
+
});
|
|
26
|
+
});
|
package/build/schemas.js
CHANGED
|
@@ -16,16 +16,16 @@ export const listOneIntegrationsInputSchema = {};
|
|
|
16
16
|
* Schema for searching platform actions
|
|
17
17
|
*/
|
|
18
18
|
export const searchOnePlatformActionsInputSchema = {
|
|
19
|
-
platform: z.string().describe("The platform name to search actions for (e.g., 'ship-station', 'shopify'). This is the kebab-case
|
|
19
|
+
platform: z.string().describe("The platform name to search actions for (e.g., 'ship-station', 'shopify'). This is the kebab-case platform name from list_one_integrations."),
|
|
20
20
|
query: z.string().describe("The search query to find relevant actions (e.g., 'search contacts', 'create customer', 'send email'). Be specific about what you want to do."),
|
|
21
|
-
agentType: z.enum(["execute", "knowledge"]).optional().describe("The type of agent context: 'execute' if the user wants to execute an action, 'knowledge' if they want to get information or write code. Defaults to '
|
|
21
|
+
agentType: z.enum(["execute", "knowledge"]).optional().describe("The type of agent context: 'execute' if the user wants to execute an action, 'knowledge' if they want to get information or write code. Defaults to 'execute' when this server can execute actions (knowledge-only servers always use 'knowledge').")
|
|
22
22
|
};
|
|
23
23
|
/**
|
|
24
24
|
* Schema for getting action knowledge
|
|
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
|
|
28
|
+
platform: z.string().describe("The platform name to get knowledge for (e.g., 'ship-station', 'shopify'). This is the kebab-case platform name from list_one_integrations."),
|
|
29
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
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
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.")
|
|
@@ -75,11 +75,11 @@ export const listOneIntegrationsOutputSchema = {
|
|
|
75
75
|
platform: z.string(),
|
|
76
76
|
name: z.string(),
|
|
77
77
|
category: z.string()
|
|
78
|
-
})).describe("
|
|
78
|
+
})).optional().describe("Platforms that can be connected. Only listed in knowledge/code-gen mode"),
|
|
79
79
|
summary: z.object({
|
|
80
80
|
connectedCount: z.number(),
|
|
81
|
-
availableCount: z.number()
|
|
82
|
-
}).describe("
|
|
81
|
+
availableCount: z.number().optional()
|
|
82
|
+
}).describe("Counts of connections (and available platforms in knowledge/code-gen mode)")
|
|
83
83
|
};
|
|
84
84
|
/**
|
|
85
85
|
* Output schemas for search_one_platform_actions tool
|
|
@@ -102,7 +102,7 @@ export const searchOnePlatformActionsOutputSchema = {
|
|
|
102
102
|
*/
|
|
103
103
|
export const listOneIntegrationsToolConfig = {
|
|
104
104
|
title: "List One Integrations",
|
|
105
|
-
description: "List
|
|
105
|
+
description: "List the platforms the user has connected on One. ALWAYS call this tool first in any workflow. Each connection has the kebab-case `platform` name (e.g., 'ship-station', 'shopify') and the `key` (the connectionKey for execute_one_action) you'll need for subsequent tool calls. Each connection carries an `access` field describing what you may run on it: `full`, a set of allowed HTTP `methods`, or a specific list of `actions` (each with `actionId`, `title`, `method`). When a connection is action-scoped, its `actions` are exactly what may run, so you need not search.",
|
|
106
106
|
inputSchema: listOneIntegrationsInputSchema,
|
|
107
107
|
outputSchema: listOneIntegrationsOutputSchema
|
|
108
108
|
};
|