@withone/mcp 1.3.0 → 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/build/client.js +77 -12
- package/build/client.test.js +101 -0
- 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
|
*/
|
|
@@ -62,11 +118,11 @@ export class OneClient {
|
|
|
62
118
|
]);
|
|
63
119
|
const [connectionsResult, connectorsResult] = results;
|
|
64
120
|
if (connectionsResult.status === 'rejected') {
|
|
65
|
-
console.error("Failed to fetch connections:", connectionsResult.reason);
|
|
121
|
+
console.error("Failed to fetch connections:", describeError(connectionsResult.reason));
|
|
66
122
|
this.connections = [];
|
|
67
123
|
}
|
|
68
124
|
if (connectorsResult.status === 'rejected') {
|
|
69
|
-
console.error("Failed to fetch connectors:", connectorsResult.reason);
|
|
125
|
+
console.error("Failed to fetch connectors:", describeError(connectorsResult.reason));
|
|
70
126
|
this.connectors = [];
|
|
71
127
|
}
|
|
72
128
|
this.isInitialized = true;
|
|
@@ -105,7 +161,7 @@ export class OneClient {
|
|
|
105
161
|
this.connections = await fetchPaginatedData(url, headers, Object.keys(additionalParams).length > 0 ? additionalParams : undefined);
|
|
106
162
|
}
|
|
107
163
|
catch (error) {
|
|
108
|
-
console.error("Failed to fetch connections:", error);
|
|
164
|
+
console.error("Failed to fetch connections:", describeError(error));
|
|
109
165
|
this.connections = [];
|
|
110
166
|
throw error;
|
|
111
167
|
}
|
|
@@ -120,7 +176,7 @@ export class OneClient {
|
|
|
120
176
|
this.connectors = await fetchPaginatedData(url, headers);
|
|
121
177
|
}
|
|
122
178
|
catch (error) {
|
|
123
|
-
console.error("Failed to fetch connection definitions:", error);
|
|
179
|
+
console.error("Failed to fetch connection definitions:", describeError(error));
|
|
124
180
|
this.connectors = [];
|
|
125
181
|
throw error;
|
|
126
182
|
}
|
|
@@ -162,7 +218,7 @@ export class OneClient {
|
|
|
162
218
|
return response.data || [];
|
|
163
219
|
}
|
|
164
220
|
catch (error) {
|
|
165
|
-
console.error("Error searching available actions:", error);
|
|
221
|
+
console.error("Error searching available actions:", describeError(error));
|
|
166
222
|
if (axios.isAxiosError(error)) {
|
|
167
223
|
throw new Error(`Failed to search available actions: ${error.response?.status} ${error.response?.statusText}`);
|
|
168
224
|
}
|
|
@@ -204,7 +260,7 @@ export class OneClient {
|
|
|
204
260
|
return actions[0];
|
|
205
261
|
}
|
|
206
262
|
catch (error) {
|
|
207
|
-
console.error("Error fetching action details:", error);
|
|
263
|
+
console.error("Error fetching action details:", describeError(error));
|
|
208
264
|
if (axios.isAxiosError(error)) {
|
|
209
265
|
throw new Error(`Failed to fetch action details: ${error.response?.status} ${error.response?.statusText}`);
|
|
210
266
|
}
|
|
@@ -232,7 +288,7 @@ export class OneClient {
|
|
|
232
288
|
};
|
|
233
289
|
}
|
|
234
290
|
catch (error) {
|
|
235
|
-
console.error("Error fetching action knowledge:", error);
|
|
291
|
+
console.error("Error fetching action knowledge:", describeError(error));
|
|
236
292
|
throw error;
|
|
237
293
|
}
|
|
238
294
|
}
|
|
@@ -267,7 +323,7 @@ export class OneClient {
|
|
|
267
323
|
};
|
|
268
324
|
}
|
|
269
325
|
catch (error) {
|
|
270
|
-
console.error(`Failed to resolve allowed action ${actionId}:`, error);
|
|
326
|
+
console.error(`Failed to resolve allowed action ${actionId}:`, describeError(error));
|
|
271
327
|
return null;
|
|
272
328
|
}
|
|
273
329
|
}));
|
|
@@ -286,12 +342,21 @@ export class OneClient {
|
|
|
286
342
|
const action = preloadedAction ?? await this.getActionDetails(actionId);
|
|
287
343
|
const method = action.method;
|
|
288
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'));
|
|
289
354
|
const requestHeaders = {
|
|
290
|
-
|
|
355
|
+
'Content-Type': contentType,
|
|
356
|
+
...stripReservedHeaders(headers),
|
|
357
|
+
...authHeaders,
|
|
291
358
|
'x-one-connection-key': connectionKey,
|
|
292
359
|
'x-one-action-id': action._id,
|
|
293
|
-
'Content-Type': contentType,
|
|
294
|
-
...headers
|
|
295
360
|
};
|
|
296
361
|
const finalActionPath = pathVariables
|
|
297
362
|
? replacePathVariables(action.path, pathVariables)
|
|
@@ -368,7 +433,7 @@ export class OneClient {
|
|
|
368
433
|
};
|
|
369
434
|
}
|
|
370
435
|
catch (error) {
|
|
371
|
-
console.error("Error executing passthrough request:", error);
|
|
436
|
+
console.error("Error executing passthrough request:", describeError(error));
|
|
372
437
|
if (axios.isAxiosError(error)) {
|
|
373
438
|
throw new Error(`Failed to execute passthrough request: ${error.response?.status} ${error.response?.statusText}`);
|
|
374
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
|
+
});
|