@withone/mcp 2.2.1 → 2.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
@@ -10,8 +10,18 @@ import axios from 'axios';
10
10
  import FormData from 'form-data';
11
11
  import { callerHeaders } from './caller.js';
12
12
  import { fetchPaginatedData, replacePathVariables } from './helpers.js';
13
+ import { ToolError, oneApiError, oneRefusal } from './tool-error.js';
13
14
  /** Header names that only the server may set on an outgoing request. */
14
15
  const RESERVED_HEADER_PREFIX = 'x-one-';
16
+ /** The prefix One renames every upstream response header to, so its presence marks a response the platform gave. */
17
+ const PLATFORM_HEADER_PREFIX = 'x-passthrough-';
18
+ /**
19
+ * Whether a passthrough response came from the platform rather than from One
20
+ * refusing the call before it reached the platform.
21
+ */
22
+ export function answeredByPlatform(headers) {
23
+ return Object.keys(headers).some((name) => name.toLowerCase().startsWith(PLATFORM_HEADER_PREFIX));
24
+ }
15
25
  /**
16
26
  * Drops any caller-supplied header that One itself controls (`x-one-secret`,
17
27
  * `x-one-connection-key`, `x-one-action-id`, ...). Comparison is
@@ -194,7 +204,7 @@ export class OneClient {
194
204
  * @param caller - The MCP client that connected and the model it named,
195
205
  * sent so One records who asked
196
206
  * @returns One answer per intent, in the order asked
197
- * @throws {Error} Naming core's reason when it refuses the request
207
+ * @throws {ToolError} Naming core's reason when it refuses the request
198
208
  */
199
209
  async findActions(requests, task, knowledgeAgent, caller = {}) {
200
210
  try {
@@ -203,22 +213,18 @@ export class OneClient {
203
213
  }
204
214
  catch (error) {
205
215
  console.error("Error finding actions:", describeError(error));
206
- if (axios.isAxiosError(error)) {
207
- const reason = error.response?.data?.message;
208
- throw new Error(`Failed to find actions: ${reason ?? `${error.response?.status} ${error.response?.statusText}`}`);
209
- }
210
- throw new Error("Failed to find actions");
216
+ throw oneApiError("Failed to find actions", error);
211
217
  }
212
218
  }
213
219
  /**
214
220
  * Gets full action details by ID
215
221
  * @param actionId - The action ID to get details for
216
222
  * @returns The full action object
217
- * @throws {Error} If action ID is not provided or API request fails
223
+ * @throws {ToolError} If the action ID is missing or unknown, or the API request fails
218
224
  */
219
225
  async getActionDetails(actionId) {
220
226
  if (!actionId?.trim()) {
221
- throw new Error("Action ID is required");
227
+ throw new ToolError("An actionId is required. Use one returned by find_one_actions.");
222
228
  }
223
229
  const cached = this.actionDetailsCache.get(actionId);
224
230
  if (cached && cached.expiresAt > Date.now()) {
@@ -236,7 +242,7 @@ export class OneClient {
236
242
  });
237
243
  const actions = response.data?.rows || [];
238
244
  if (actions.length === 0) {
239
- throw new Error(`Action with ID ${actionId} not found`);
245
+ throw new ToolError(`Action ${actionId} not found. Use an actionId returned by find_one_actions.`);
240
246
  }
241
247
  this.actionDetailsCache.set(actionId, {
242
248
  details: actions[0],
@@ -245,11 +251,11 @@ export class OneClient {
245
251
  return actions[0];
246
252
  }
247
253
  catch (error) {
248
- console.error("Error fetching action details:", describeError(error));
249
- if (axios.isAxiosError(error)) {
250
- throw new Error(`Failed to fetch action details: ${error.response?.status} ${error.response?.statusText}`);
254
+ if (error instanceof ToolError) {
255
+ throw error;
251
256
  }
252
- throw new Error("Failed to fetch action details");
257
+ console.error("Error fetching action details:", describeError(error));
258
+ throw oneApiError("Failed to fetch action details", error);
253
259
  }
254
260
  }
255
261
  /**
@@ -293,8 +299,8 @@ export class OneClient {
293
299
  /**
294
300
  * Executes a passthrough request to a third-party API through One
295
301
  * @param args - The execution arguments containing all request details
296
- * @returns Object containing sanitized request config and response data
297
- * @throws {Error} If the request fails
302
+ * @returns The platform's response, whatever its status, with the sanitized request config
303
+ * @throws {ToolError} If One refuses the call or cannot be reached
298
304
  */
299
305
  async executePassthroughRequest(args, preloadedAction) {
300
306
  const { actionId, connectionKey, data, pathVariables, queryParams, headers, isFormData, isFormUrlEncoded, } = args;
@@ -385,20 +391,25 @@ export class OneClient {
385
391
  'x-one-secret': '***REDACTED***'
386
392
  }
387
393
  };
394
+ let response;
388
395
  try {
389
- const response = await axios(requestConfig);
390
- return {
391
- requestConfig: sanitizedConfig,
392
- responseData: response.data
393
- };
396
+ // Every status resolves: a platform's 4xx or 5xx is its answer, which
397
+ // the agent reads; only One refusing the call is a tool error.
398
+ response = await axios({ ...requestConfig, validateStatus: () => true });
394
399
  }
395
400
  catch (error) {
396
401
  console.error("Error executing passthrough request:", describeError(error));
397
- if (axios.isAxiosError(error)) {
398
- throw new Error(`Failed to execute passthrough request: ${error.response?.status} ${error.response?.statusText}`);
399
- }
400
- throw new Error("Failed to execute passthrough request");
402
+ throw oneApiError("Failed to execute One action", error, true);
403
+ }
404
+ if (response.status >= 400 && !answeredByPlatform(response.headers)) {
405
+ console.error("One refused the passthrough request:", { status: response.status, url: url.split('?')[0], data: summarizeErrorData(response.data) });
406
+ throw oneRefusal("Failed to execute One action", response.status, response.data);
401
407
  }
408
+ return {
409
+ responseData: response.data,
410
+ status: response.status,
411
+ requestConfig: sanitizedConfig
412
+ };
402
413
  }
403
414
  /**
404
415
  * Gets the base URL for the One API
@@ -1,10 +1,11 @@
1
1
  import { describe, it, before, after, beforeEach } from 'node:test';
2
2
  import assert from 'node:assert/strict';
3
3
  import http from 'node:http';
4
- import { OneClient, stripReservedHeaders, describeError } from './client.js';
4
+ import { OneClient, stripReservedHeaders, describeError, answeredByPlatform } from './client.js';
5
+ import { ToolError } from './tool-error.js';
5
6
  // A passthrough call must never let the caller override One's own routing/auth
6
7
  // headers (the scoping bypass), and a failed call must never log the secret.
7
- const SECRET = 'sk_test_REALSECRET_abc123';
8
+ const SECRET = 'test-one-secret-not-a-real-key';
8
9
  const ACTION = { _id: 'conn_mod_def::ALLOWED_ACTION', method: 'GET', path: '/v1/things', tags: [] };
9
10
  const exec = (extra) => ({
10
11
  platform: 'test',
@@ -16,13 +17,13 @@ describe("executePassthroughRequest keeps One's headers authoritative", () => {
16
17
  let server;
17
18
  let base = '';
18
19
  let received = [];
19
- let failBody = null;
20
+ let reply = null;
20
21
  before(async () => {
21
22
  server = http.createServer((req, res) => {
22
23
  received.push({ headers: req.headers });
23
- if (failBody !== null) {
24
- res.writeHead(422, { 'Content-Type': 'application/json' });
25
- res.end(failBody);
24
+ if (reply !== null) {
25
+ res.writeHead(reply.status, { 'Content-Type': 'application/json', ...reply.headers });
26
+ res.end(reply.body);
26
27
  return;
27
28
  }
28
29
  res.writeHead(200, { 'Content-Type': 'application/json' });
@@ -36,7 +37,7 @@ describe("executePassthroughRequest keeps One's headers authoritative", () => {
36
37
  });
37
38
  beforeEach(() => {
38
39
  received = [];
39
- failBody = null;
40
+ reply = null;
40
41
  });
41
42
  it('a caller cannot override the x-one-* routing/auth headers', async () => {
42
43
  const client = new OneClient(SECRET, base);
@@ -68,8 +69,41 @@ describe("executePassthroughRequest keeps One's headers authoritative", () => {
68
69
  await client.executePassthroughRequest(exec({ headers: { 'content-type': 'text/plain' } }), ACTION);
69
70
  assert.equal(received[0].headers['content-type'], 'text/plain');
70
71
  });
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) });
72
+ it("a platform's 4xx or 5xx is returned as its answer, with the status and body", async () => {
73
+ const client = new OneClient(SECRET, base);
74
+ for (const status of [404, 500]) {
75
+ reply = { status, headers: { 'X-Passthrough-Content-Type': 'application/json' }, body: JSON.stringify({ error: 'no such contact' }) };
76
+ const result = await client.executePassthroughRequest(exec({}), ACTION);
77
+ assert.equal(result.status, status);
78
+ assert.deepEqual(result.responseData, { error: 'no such contact' });
79
+ assert.deepEqual(Object.keys(result).slice(0, 2), ['responseData', 'status'], 'the payload leads');
80
+ }
81
+ });
82
+ it("a call One refuses throws a tool error with One's reason and reference", async () => {
83
+ reply = {
84
+ status: 404,
85
+ body: JSON.stringify({ type: 'passthrough_error', status: 404, key: 'not_found', message: 'Connection not found', correlation_id: 'corr-1' }),
86
+ };
87
+ const client = new OneClient(SECRET, base);
88
+ await assert.rejects(client.executePassthroughRequest(exec({}), ACTION), (e) => e instanceof ToolError && e.message.includes('Connection not found') && e.message.includes('corr-1'));
89
+ });
90
+ it('One being unreachable is a tool error that warns against a blind retry', async () => {
91
+ const closed = http.createServer();
92
+ await new Promise((resolve) => closed.listen(0, '127.0.0.1', () => resolve()));
93
+ const port = closed.address().port;
94
+ await new Promise((resolve) => closed.close(() => resolve()));
95
+ const client = new OneClient(SECRET, `http://127.0.0.1:${port}`);
96
+ const original = console.error;
97
+ console.error = () => { };
98
+ try {
99
+ await assert.rejects(client.executePassthroughRequest(exec({}), ACTION), (e) => e instanceof ToolError && /could not be reached/.test(e.message) && /check whether it took effect/.test(e.message));
100
+ }
101
+ finally {
102
+ console.error = original;
103
+ }
104
+ });
105
+ it('a refused call never logs the secret, and bounds the body', async () => {
106
+ reply = { status: 422, body: JSON.stringify({ message: 'upstream said no', padding: 'x'.repeat(2000) }) };
73
107
  const client = new OneClient(SECRET, base);
74
108
  const logged = [];
75
109
  const original = console.error;
@@ -90,7 +124,14 @@ describe("executePassthroughRequest keeps One's headers authoritative", () => {
90
124
  assert.ok(!log.includes(SECRET), 'the secret never reaches the log');
91
125
  assert.ok(log.includes('422') && log.includes('upstream said no'), 'the log still says what failed');
92
126
  assert.ok(log.includes('truncated'), 'a huge upstream body is bounded');
93
- assert.ok(message.includes('422'), 'the thrown tool error carries the status');
127
+ assert.ok(message.includes('422') && message.includes('upstream said no'), "the tool error carries the status and One's reason");
128
+ });
129
+ });
130
+ describe('answeredByPlatform', () => {
131
+ it('reads a renamed upstream header, in any casing, as the platform answering', () => {
132
+ assert.equal(answeredByPlatform({ 'x-passthrough-content-type': 'application/json' }), true);
133
+ assert.equal(answeredByPlatform({ 'X-Passthrough-Date': 'now' }), true);
134
+ assert.equal(answeredByPlatform({ 'content-type': 'application/json', 'x-one-correlation-id': 'c' }), false);
94
135
  });
95
136
  });
96
137
  describe('stripReservedHeaders', () => {
package/build/find.js CHANGED
@@ -12,6 +12,7 @@
12
12
  * @fileoverview find_one_actions logic, free of HTTP so it can be tested
13
13
  * @author One
14
14
  */
15
+ import { ToolError } from './tool-error.js';
15
16
  import { buildKnowledgeResponse, isActionAllowed, isMethodAllowed, platformEnvSegment } from './helpers.js';
16
17
  /** How many intents one find answers. Core enforces the same cap. */
17
18
  export const FIND_MAX_INTENTS = 10;
@@ -27,13 +28,10 @@ export const FIND_EXECUTE_HINT = 'Run it with `execute_one_action`: `platform`,
27
28
  export const REFUSED_LOAD = 'no such action, or this server may not use it';
28
29
  /** What a call that is neither a find nor a load is told. */
29
30
  export const FIND_ARGS_ERROR = 'Send either `requests` (to find actions, with an optional `task`) or `load` (for more documentation, without `task`), not both and not neither.';
30
- /** A call the agent has to fix, reported to it rather than failing the server. */
31
- export class FindArgsError extends Error {
32
- }
33
31
  /**
34
32
  * Answers a `find_one_actions` call: finds actions for `requests`, or serves
35
33
  * more documentation for `load`.
36
- * @throws {FindArgsError} When the call is neither, both, or `task` comes with `load`
34
+ * @throws {ToolError} When the call is neither, both, or `task` comes with `load`
37
35
  */
38
36
  export async function findOneActions(args, deps, settings) {
39
37
  if (args.requests && !args.load) {
@@ -42,7 +40,7 @@ export async function findOneActions(args, deps, settings) {
42
40
  if (args.load && !args.requests && args.task === undefined) {
43
41
  return load(args.load, deps, settings);
44
42
  }
45
- throw new FindArgsError(FIND_ARGS_ERROR);
43
+ throw new ToolError(FIND_ARGS_ERROR);
46
44
  }
47
45
  /** An action as the agent refers to it: title, method, path and `actionId`. */
48
46
  export function actionHeading(title, method, path, actionId) {
@@ -150,7 +148,7 @@ function finish(sections, documented, settings) {
150
148
  }
151
149
  async function find(requests, task, deps, settings, caller) {
152
150
  if (requests.length === 0 || requests.length > FIND_MAX_INTENTS) {
153
- throw new FindArgsError(`Send between 1 and ${FIND_MAX_INTENTS} requests.`);
151
+ throw new ToolError(`Send between 1 and ${FIND_MAX_INTENTS} requests.`);
154
152
  }
155
153
  const reachable = requests.filter((r) => reaches(settings, r.platform));
156
154
  const answers = reachable.length > 0 ? await deps.findActions(reachable, task, settings.knowledgeAgent, caller) : [];
@@ -267,7 +265,7 @@ function boundDocuments(plans, documents, settings) {
267
265
  }
268
266
  async function load(loads, deps, settings) {
269
267
  if (loads.length === 0 || loads.length > FIND_MAX_LOADS) {
270
- throw new FindArgsError(`Send between 1 and ${FIND_MAX_LOADS} loads.`);
268
+ throw new ToolError(`Send between 1 and ${FIND_MAX_LOADS} loads.`);
271
269
  }
272
270
  const entries = await Promise.all(loads.map(async (entry) => {
273
271
  const refused = { text: `\`${entry.action_id}\`: ${REFUSED_LOAD}`, documented: false };
@@ -1,6 +1,7 @@
1
1
  import { describe, it } from 'node:test';
2
2
  import assert from 'node:assert/strict';
3
- import { FIND_ARGS_ERROR, FIND_EXECUTE_HINT, FindArgsError, REFUSED_LOAD, findOneActions, } from './find.js';
3
+ import { FIND_ARGS_ERROR, FIND_EXECUTE_HINT, REFUSED_LOAD, findOneActions, } from './find.js';
4
+ import { ToolError } from './tool-error.js';
4
5
  // Pure: core and the knowledge endpoint are stubbed, so no network is touched.
5
6
  const LARGE = [
6
7
  '# Send Email',
@@ -70,7 +71,7 @@ describe('find_one_actions', () => {
70
71
  });
71
72
  it('takes requests or load, never both, never neither, and no task with a load', async () => {
72
73
  for (const args of [{}, { requests: intent, load: [{ action_id: 'a' }] }, { load: [{ action_id: 'a' }], task: 't' }]) {
73
- await assert.rejects(findOneActions(args, deps([answer()]), settings()), (e) => e instanceof FindArgsError && e.message === FIND_ARGS_ERROR);
74
+ await assert.rejects(findOneActions(args, deps([answer()]), settings()), (e) => e instanceof ToolError && e.message === FIND_ARGS_ERROR);
74
75
  }
75
76
  });
76
77
  it('names each action by actionId, documents the pick from its raw knowledge, and ends on the execute arguments once', async () => {
package/build/helpers.js CHANGED
@@ -7,6 +7,7 @@
7
7
  * @author One
8
8
  */
9
9
  import axios from 'axios';
10
+ import { ToolError } from './tool-error.js';
10
11
  import { parseSections, buildDigest, renderDigestBanner, renderDigestNotice, selectSections, flattenSections, parseSectionFlag, collapseSections, omittedSections, } from './knowledge-sections.js';
11
12
  /**
12
13
  * Paginates through API results by repeatedly calling a fetch function until all data is retrieved.
@@ -203,7 +204,7 @@ export function replacePathVariables(path, variables) {
203
204
  const name = variable.trim();
204
205
  const value = name in (variables ?? {}) ? variables[name] : folded.get(foldVariableName(name));
205
206
  if (value === undefined || value === null || value === '') {
206
- throw new Error(`Missing value for path variable: ${name}. This action's path is ${path}; pass pathVariables with: ${[...new Set(expected)].join(', ')}.`);
207
+ throw new ToolError(`Missing value for path variable: ${name}. This action's path is ${path}; pass pathVariables with: ${[...new Set(expected)].join(', ')}.`);
207
208
  }
208
209
  return encodeURIComponent(value.toString());
209
210
  };
package/build/index.js CHANGED
@@ -12,10 +12,12 @@ 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
14
  import { isMethodAllowed, isActionAllowed, buildIntegrationsResponse, } from './helpers.js';
15
- import { FindArgsError, findOneActions } from './find.js';
15
+ import { findOneActions } from './find.js';
16
+ import { ToolError, toolErrorResult } from './tool-error.js';
16
17
  import { listOneIntegrationsToolConfig, findOneActionsToolConfig, executeOneActionToolConfig } from './schemas.js';
18
+ import { createRequire } from 'node:module';
17
19
  const SERVER_NAME = "one-mcp-server";
18
- const SERVER_VERSION = "1.0.0";
20
+ const SERVER_VERSION = createRequire(import.meta.url)('../package.json').version;
19
21
  const ONE_SECRET = process.env.ONE_SECRET;
20
22
  if (!ONE_SECRET) {
21
23
  console.error("ONE_SECRET environment variable is required");
@@ -137,11 +139,8 @@ async function handleFindOneActions(args) {
137
139
  catch (error) {
138
140
  // A call the agent has to fix goes back to it as a tool error it can read
139
141
  // and correct, rather than a protocol failure.
140
- if (error instanceof FindArgsError) {
141
- return {
142
- content: [{ type: "text", text: error.message }],
143
- isError: true,
144
- };
142
+ if (error instanceof ToolError) {
143
+ return toolErrorResult(error);
145
144
  }
146
145
  throw new McpError(ErrorCode.InternalError, `Failed to find actions: ${error instanceof Error ? error.message : 'Unknown error'}`);
147
146
  }
@@ -149,17 +148,17 @@ async function handleFindOneActions(args) {
149
148
  async function handleExecuteOneAction(args) {
150
149
  try {
151
150
  if (!isActionAllowed(args.actionId, ONE_ACTION_IDS)) {
152
- throw new McpError(ErrorCode.InvalidRequest, `Action "${args.actionId}" is not in the allowed action list`);
151
+ throw new ToolError(`Action "${args.actionId}" is not one this server may run. Use an action listed in list_one_integrations or returned by find_one_actions.`);
153
152
  }
154
153
  if (!ONE_CONNECTION_KEYS.includes("*")) {
155
154
  const allowedKeys = oneClient.getUserConnections().map(c => c.key);
156
155
  if (!allowedKeys.includes(args.connectionKey)) {
157
- throw new McpError(ErrorCode.InvalidRequest, `Connection key "${args.connectionKey}" is not allowed`);
156
+ throw new ToolError(`Connection key "${args.connectionKey}" is not one this server may use. Use a connection key from list_one_integrations.`);
158
157
  }
159
158
  }
160
159
  const actionDetails = await oneClient.getActionDetails(args.actionId);
161
160
  if (!isMethodAllowed(actionDetails.method, ONE_PERMISSIONS)) {
162
- throw new McpError(ErrorCode.InvalidRequest, `Method "${actionDetails.method}" is not allowed under "${ONE_PERMISSIONS}" permission level`);
161
+ throw new ToolError(`Action "${args.actionId}" is a ${actionDetails.method.toUpperCase()} request, which this server's "${ONE_PERMISSIONS}" permission level does not allow, so it cannot be run here.`);
163
162
  }
164
163
  const result = await oneClient.executePassthroughRequest(args, actionDetails);
165
164
  return {
@@ -169,9 +168,13 @@ async function handleExecuteOneAction(args) {
169
168
  text: JSON.stringify(result, null, 2),
170
169
  },
171
170
  ],
171
+ structuredContent: result,
172
172
  };
173
173
  }
174
174
  catch (error) {
175
+ if (error instanceof ToolError) {
176
+ return toolErrorResult(error);
177
+ }
175
178
  throw new McpError(ErrorCode.InternalError, `Failed to execute One action: ${error instanceof Error ? error.message : 'Unknown error'}`);
176
179
  }
177
180
  }
package/build/schemas.js CHANGED
@@ -34,15 +34,30 @@ export const findOneActionsInputSchema = {
34
34
  * Schema for executing One actions
35
35
  */
36
36
  export const executeOneActionInputSchema = {
37
- platform: z.string().describe("Platform name"),
38
- actionId: z.string().describe("The actionId from find_one_actions"),
39
- connectionKey: z.string().describe("Key of the connection to use"),
40
- data: z.any().optional().describe("Request data (for POST, PUT, etc.)"),
41
- pathVariables: z.record(z.union([z.string(), z.number(), z.boolean()])).optional().describe("Variables to replace in the path"),
42
- queryParams: z.record(z.any()).optional().describe("Query parameters"),
43
- headers: z.record(z.string()).optional().describe("Additional headers"),
44
- isFormData: z.boolean().optional().describe("Whether to send data as multipart/form-data"),
45
- isFormUrlEncoded: z.boolean().optional().describe("Whether to send data as application/x-www-form-urlencoded")
37
+ platform: z.string().describe('The kebab-case platform of the connection (e.g. "gmail"), as list_one_integrations names it.'),
38
+ actionId: z.string().describe("The actionId of the action to run, as returned when the action was found (or from the connection's `access` list)."),
39
+ connectionKey: z.string().describe("The connection key for the caller's connection (from list_one_integrations)."),
40
+ data: z.any().optional().describe("The request body. JSON by default; encoded as form data when isFormData or isFormUrlEncoded is set."),
41
+ pathVariables: z.record(z.union([z.string(), z.number(), z.boolean()])).optional().describe("Substitutions for {var} / {{var}} placeholders in the action path."),
42
+ queryParams: z.record(z.any()).optional().describe("Query-string parameters appended to the request."),
43
+ headers: z.record(z.string()).optional().describe("Extra request headers to forward to the upstream platform."),
44
+ isFormData: z.boolean().optional().describe("Encode the body as multipart/form-data."),
45
+ isFormUrlEncoded: z.boolean().optional().describe("Encode the body as application/x-www-form-urlencoded.")
46
+ };
47
+ /**
48
+ * Output schema for execute_one_action. `responseData` comes first so a client
49
+ * that cuts long output short still shows the payload.
50
+ */
51
+ export const executeOneActionOutputSchema = {
52
+ responseData: z.any().describe("The platform's response body, whatever its status."),
53
+ status: z.number().describe("The platform's HTTP status. A 4xx or 5xx is the platform's answer: read `responseData` for its reason."),
54
+ requestConfig: z.object({
55
+ url: z.string(),
56
+ method: z.string(),
57
+ headers: z.record(z.string()),
58
+ params: z.record(z.any()).optional(),
59
+ data: z.any().optional()
60
+ }).describe("The request as sent, with the One secret redacted.")
46
61
  };
47
62
  /**
48
63
  * Output schemas for list_one_integrations tool
@@ -81,6 +96,16 @@ export const listOneIntegrationsOutputSchema = {
81
96
  availableCount: z.number().optional()
82
97
  }).describe("Counts of connections (and available platforms in knowledge/code-gen mode)")
83
98
  };
99
+ /**
100
+ * Hints for a tool that only reads One's own data, matching the remote MCP's:
101
+ * clients may run it without asking the user.
102
+ */
103
+ const READ_ONLY_ANNOTATIONS = {
104
+ readOnlyHint: true,
105
+ destructiveHint: false,
106
+ idempotentHint: true,
107
+ openWorldHint: false
108
+ };
84
109
  /**
85
110
  * Tool configuration objects for registerTool
86
111
  */
@@ -88,17 +113,26 @@ export const listOneIntegrationsToolConfig = {
88
113
  title: "List One Integrations",
89
114
  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.",
90
115
  inputSchema: listOneIntegrationsInputSchema,
91
- outputSchema: listOneIntegrationsOutputSchema
116
+ outputSchema: listOneIntegrationsOutputSchema,
117
+ annotations: READ_ONLY_ANNOTATIONS
92
118
  };
93
119
  export const findOneActionsToolConfig = {
94
120
  title: "Find Platform Actions",
95
121
  description: "Use this to find the API actions a task needs, on any platforms, in one call: send one `requests` entry per operation (platform and a short intent) and get back, for each, the action to use with its documentation, plus a few alternatives. Large documents come back as a digest; call again with `load` for a section it omitted, the whole document, the table of contents, or an alternative's documentation. Results are limited to what this server's settings allow. Call list_one_integrations first to see the connected platforms.",
96
- inputSchema: findOneActionsInputSchema
122
+ inputSchema: findOneActionsInputSchema,
123
+ annotations: READ_ONLY_ANNOTATIONS
97
124
  };
98
125
  export const executeOneActionToolConfig = {
99
126
  title: "Execute One Action",
100
127
  description: "Execute a One action to perform actual operations on third-party platforms. CRITICAL: Only call this when the user's intent is to EXECUTE an action (e.g., 'read my last Gmail email', 'fetch 5 contacts from HubSpot', 'create a task in Asana'). DO NOT call this when the user wants to BUILD or CREATE code/forms/applications - in those cases, stop after find_one_actions and provide implementation guidance instead. REQUIRED WORKFLOW: Must call find_one_actions first, which returns the action's documentation. If uncertain about execution intent or parameters, ask for confirmation before proceeding.",
101
- inputSchema: executeOneActionInputSchema
128
+ inputSchema: executeOneActionInputSchema,
129
+ outputSchema: executeOneActionOutputSchema,
130
+ annotations: {
131
+ readOnlyHint: false,
132
+ destructiveHint: true,
133
+ idempotentHint: false,
134
+ openWorldHint: true
135
+ }
102
136
  };
103
137
  /**
104
138
  * Zod object schemas for type inference (for internal use)
@@ -0,0 +1,63 @@
1
+ import { describe, it } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
4
+ import { Client } from '@modelcontextprotocol/sdk/client/index.js';
5
+ import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
6
+ import { executeOneActionToolConfig, findOneActionsToolConfig, listOneIntegrationsToolConfig, } from './schemas.js';
7
+ import { ToolError, toolErrorResult } from './tool-error.js';
8
+ // The hints and the execute output schema match the remote MCP's, so a client
9
+ // treats a tool the same on either surface.
10
+ const PLATFORM_404 = {
11
+ responseData: { error: 'no such contact' },
12
+ status: 404,
13
+ requestConfig: { url: 'https://api.withone.ai/v1/passthrough/contacts/1', method: 'GET', headers: { 'x-one-secret': '***REDACTED***' } },
14
+ };
15
+ /** A client connected to a server offering the three tools, execute answering with `execute`. */
16
+ async function connect(execute) {
17
+ const server = new McpServer({ name: 'test', version: '0.0.0' });
18
+ server.registerTool('list_one_integrations', listOneIntegrationsToolConfig, async () => ({
19
+ content: [],
20
+ structuredContent: { connections: [], summary: { connectedCount: 0 } },
21
+ }));
22
+ server.registerTool('find_one_actions', findOneActionsToolConfig, async () => ({ content: [] }));
23
+ server.registerTool('execute_one_action', executeOneActionToolConfig, async () => execute());
24
+ const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
25
+ const client = new Client({ name: 'test-client', version: '0.0.0' });
26
+ await Promise.all([server.connect(serverTransport), client.connect(clientTransport)]);
27
+ return client;
28
+ }
29
+ const CALL = { name: 'execute_one_action', arguments: { platform: 'hubspot', actionId: 'a', connectionKey: 'k' } };
30
+ describe('tool annotations', () => {
31
+ it('mark execute destructive and open-world, and the rest read-only', async () => {
32
+ const client = await connect(() => ({ content: [], structuredContent: PLATFORM_404 }));
33
+ const { tools } = await client.listTools();
34
+ assert.equal(tools.length, 3);
35
+ for (const tool of tools) {
36
+ const hints = tool.annotations;
37
+ assert.ok(hints && hints.readOnlyHint !== undefined && hints.openWorldHint !== undefined, `${tool.name} sets its hints`);
38
+ if (tool.name === 'execute_one_action') {
39
+ assert.deepEqual([hints.readOnlyHint, hints.destructiveHint, hints.idempotentHint, hints.openWorldHint], [false, true, false, true]);
40
+ }
41
+ else {
42
+ assert.deepEqual([hints.readOnlyHint, hints.destructiveHint, hints.idempotentHint, hints.openWorldHint], [true, false, true, false], `${tool.name} is read-only`);
43
+ }
44
+ }
45
+ });
46
+ });
47
+ describe('execute_one_action output', () => {
48
+ it("accepts a platform's 404 as a result, not an error", async () => {
49
+ const client = await connect(() => ({
50
+ content: [{ type: 'text', text: JSON.stringify(PLATFORM_404) }],
51
+ structuredContent: PLATFORM_404,
52
+ }));
53
+ const result = await client.callTool(CALL);
54
+ assert.equal(result.isError, undefined);
55
+ assert.deepEqual(result.structuredContent, PLATFORM_404);
56
+ });
57
+ it('reports a tool error as an isError result the agent reads', async () => {
58
+ const client = await connect(() => toolErrorResult(new ToolError('Connection key "k" is not one this server may use.')));
59
+ const result = await client.callTool(CALL);
60
+ assert.equal(result.isError, true);
61
+ assert.deepEqual(result.content, [{ type: 'text', text: 'Connection key "k" is not one this server may use.' }]);
62
+ });
63
+ });
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Tool errors the agent reads and acts on
3
+ *
4
+ * A refusal, a missing argument, or One being unreachable is reported to the
5
+ * agent as an `isError` tool result, which every MCP client shows the model.
6
+ * A protocol error is reserved for a fault in this server itself.
7
+ *
8
+ * @fileoverview The tool-error type and how One API failures become one
9
+ * @author One
10
+ */
11
+ import axios from 'axios';
12
+ /** A failure the agent can read and correct, returned as an `isError` result rather than a protocol error. */
13
+ export class ToolError extends Error {
14
+ }
15
+ /** The `isError` result a ToolError is reported as. */
16
+ export function toolErrorResult(error) {
17
+ return {
18
+ content: [{ type: "text", text: error.message }],
19
+ isError: true,
20
+ };
21
+ }
22
+ /**
23
+ * Reports a response One itself refused, with its reason and the reference
24
+ * support can trace it by.
25
+ * @param what - What was being done, e.g. "Failed to execute One action"
26
+ */
27
+ export function oneRefusal(what, status, body) {
28
+ const { message, correlation_id } = (body && typeof body === 'object' ? body : {});
29
+ const reason = message ?? `status ${status}`;
30
+ const reference = correlation_id ? `, reference: ${correlation_id}` : '';
31
+ return new ToolError(`${what}: ${reason} (One answered ${status}${reference}).`);
32
+ }
33
+ /**
34
+ * Reports a failed call to the One API: One's reason when it answered, or that
35
+ * it did not answer.
36
+ * @param what - What was being done, e.g. "Failed to find actions"
37
+ * @param mayHaveRun - The call could have reached the platform, so a write must be checked before it is tried again
38
+ */
39
+ export function oneApiError(what, error, mayHaveRun = false) {
40
+ if (axios.isAxiosError(error) && error.response) {
41
+ return oneRefusal(what, error.response.status, error.response.data);
42
+ }
43
+ const retry = mayHaveRun
44
+ ? 'If this call changes data, check whether it took effect before trying again; otherwise try again.'
45
+ : 'Try again.';
46
+ return new ToolError(`${what}: One could not be reached. ${retry}`);
47
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@withone/mcp",
3
- "version": "2.2.1",
3
+ "version": "2.3.1",
4
4
  "description": "A Model Context Protocol Server for One",
5
5
  "type": "module",
6
6
  "bin": {
@@ -14,24 +14,16 @@
14
14
  "test": "node scripts/run-tests.mjs",
15
15
  "prepare": "npm run build",
16
16
  "watch": "tsc --watch",
17
- "inspector": "npx @modelcontextprotocol/inspector build/index.js",
18
- "http": "npm run build && node build/index.js",
19
- "http:dev": "npm run build && nodemon build/index.js"
17
+ "inspector": "npx @modelcontextprotocol/inspector build/index.js"
20
18
  },
21
19
  "dependencies": {
22
20
  "@modelcontextprotocol/sdk": "^1.12.1",
23
- "@vercel/mcp-adapter": "^0.9.1",
24
- "axios": "^1.10.0",
25
- "cors": "^2.8.5",
26
- "express": "^5.1.0",
21
+ "axios": "^1.20.0",
27
22
  "form-data": "^4.0.3",
28
23
  "zod": "^3.23.0"
29
24
  },
30
25
  "devDependencies": {
31
- "@types/cors": "^2.8.19",
32
- "@types/express": "^5.0.3",
33
26
  "@types/node": "^22.0.0",
34
- "nodemon": "^3.1.10",
35
27
  "tsx": "^4.23.13",
36
28
  "typescript": "^5.6.0"
37
29
  },