@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 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
- ...this.generateHeaders(),
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
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@withone/mcp",
3
- "version": "1.3.0",
3
+ "version": "1.3.1",
4
4
  "description": "A Model Context Protocol Server for One",
5
5
  "type": "module",
6
6
  "bin": {