@withone/mcp 2.2.0 → 2.3.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/README.md +2 -2
- package/build/client.js +35 -24
- package/build/client.test.js +50 -9
- package/build/find.js +5 -7
- package/build/find.test.js +3 -2
- package/build/helpers.js +2 -1
- package/build/index.js +14 -11
- package/build/schemas.js +46 -12
- package/build/schemas.test.js +63 -0
- package/build/tool-error.js +47 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
<a href="https://npmjs.com/package/@withone/mcp"><img src="https://img.shields.io/npm/v/%40withone%2Fmcp" alt="npm version"></a>
|
|
21
21
|
</p>
|
|
22
22
|
|
|
23
|
-
Connect your AI agents to
|
|
23
|
+
Connect your AI agents to 1000+ apps through a single [MCP](https://modelcontextprotocol.io) server. Find the actions a task needs with their documentation, and execute API calls across platforms, without having to manage OAuth tokens or API keys.
|
|
24
24
|
|
|
25
25
|
```bash
|
|
26
26
|
npm install -g @withone/cli
|
|
@@ -31,7 +31,7 @@ That's it. The [One CLI](https://www.npmjs.com/package/@withone/cli) will prompt
|
|
|
31
31
|
|
|
32
32
|
## Capabilities
|
|
33
33
|
|
|
34
|
-
- **
|
|
34
|
+
- **1000+ platforms.** Gmail, Slack, Shopify, HubSpot, Stripe, Linear, QuickBooks, and [more](https://app.withone.ai/tools).
|
|
35
35
|
- **Natural language execution.** "read my last gmail email", "send a message to #general on Slack"
|
|
36
36
|
- **Code generation.** "build a form to send emails using Gmail", "create a dashboard that lists my Linear projects"
|
|
37
37
|
- **No tool bloat.** Only 3 tools exposed regardless of how many platforms or actions you connect. One call finds every action a task needs, across platforms, with its documentation, so your agent's context window stays clean.
|
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 {
|
|
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
|
-
|
|
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 {
|
|
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
|
|
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
|
|
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
|
-
|
|
249
|
-
|
|
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
|
-
|
|
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
|
|
297
|
-
* @throws {
|
|
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
|
-
|
|
390
|
-
|
|
391
|
-
|
|
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
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
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
|
package/build/client.test.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
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
8
|
const SECRET = 'sk_test_REALSECRET_abc123';
|
|
@@ -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
|
|
20
|
+
let reply = null;
|
|
20
21
|
before(async () => {
|
|
21
22
|
server = http.createServer((req, res) => {
|
|
22
23
|
received.push({ headers: req.headers });
|
|
23
|
-
if (
|
|
24
|
-
res.writeHead(
|
|
25
|
-
res.end(
|
|
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
|
-
|
|
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(
|
|
72
|
-
|
|
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'),
|
|
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 {
|
|
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
|
|
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
|
|
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
|
|
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 };
|
package/build/find.test.js
CHANGED
|
@@ -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,
|
|
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
|
|
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
|
|
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 {
|
|
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 =
|
|
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");
|
|
@@ -93,7 +95,7 @@ async function handleGetIntegrations(args) {
|
|
|
93
95
|
const resolvedAllowed = (await oneClient.resolveAllowedActions(ONE_ACTION_IDS))
|
|
94
96
|
.filter(action => isMethodAllowed(action.method, ONE_PERMISSIONS));
|
|
95
97
|
// Execute mode lists only connections: every connection carries its
|
|
96
|
-
// access, and the full catalog (
|
|
98
|
+
// access, and the full catalog (1000+ platforms) would sit in the agent's
|
|
97
99
|
// context for the rest of the session. Knowledge/code-gen mode also lists
|
|
98
100
|
// available platforms so code can target one that is not connected yet,
|
|
99
101
|
// unless connection keys are scoped.
|
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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(
|
|
38
|
-
actionId: z.string().describe("The actionId from
|
|
39
|
-
connectionKey: z.string().describe("
|
|
40
|
-
data: z.any().optional().describe("
|
|
41
|
-
pathVariables: z.record(z.union([z.string(), z.number(), z.boolean()])).optional().describe("
|
|
42
|
-
queryParams: z.record(z.any()).optional().describe("Query parameters"),
|
|
43
|
-
headers: z.record(z.string()).optional().describe("
|
|
44
|
-
isFormData: z.boolean().optional().describe("
|
|
45
|
-
isFormUrlEncoded: z.boolean().optional().describe("
|
|
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
|
+
}
|