@withone/mcp 1.4.0 → 2.0.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 +12 -7
- package/build/client.js +14 -59
- package/build/find.js +393 -0
- package/build/find.test.js +134 -0
- package/build/helpers.js +7 -105
- package/build/index.js +22 -119
- package/build/knowledge-response.test.js +2 -1
- package/build/knowledge-sections.js +20 -6
- package/build/knowledge-sections.test.js +22 -0
- package/build/schemas.js +22 -45
- package/package.json +1 -1
- package/build/hidden-actions.test.js +0 -12
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 700+ apps through a single [MCP](https://modelcontextprotocol.io) server.
|
|
23
|
+
Connect your AI agents to 700+ 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
|
|
@@ -34,7 +34,7 @@ That's it. The [One CLI](https://www.npmjs.com/package/@withone/cli) will prompt
|
|
|
34
34
|
- **700+ 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
|
-
- **No tool bloat.** Only
|
|
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.
|
|
38
38
|
- **Fine-grained access control.** Restrict which actions, connections, and permission levels (read, write, admin) your agent has access to.
|
|
39
39
|
- **Secure by default.** All requests proxied through One, secrets automatically redacted, no platform API keys to manage.
|
|
40
40
|
|
|
@@ -56,15 +56,20 @@ That's it. The [One CLI](https://www.npmjs.com/package/@withone/cli) will prompt
|
|
|
56
56
|
|
|
57
57
|
## Tools
|
|
58
58
|
|
|
59
|
-
The server exposes
|
|
59
|
+
The server exposes three MCP tools:
|
|
60
60
|
|
|
61
61
|
| Tool | Description |
|
|
62
62
|
|------|-------------|
|
|
63
63
|
| `list_one_integrations` | List available platforms and active connections, each with the `access` it confers (full, methods, or specific actions) |
|
|
64
|
-
| `
|
|
65
|
-
| `get_one_action_knowledge` | Get detailed documentation for an action |
|
|
64
|
+
| `find_one_actions` | Find the action for every operation a task needs, across platforms, with its documentation, in one call; `load` fetches more of a document or an alternative's |
|
|
66
65
|
| `execute_one_action` | Execute an API action on a connected platform |
|
|
67
66
|
|
|
67
|
+
### Finding actions
|
|
68
|
+
|
|
69
|
+
`find_one_actions` takes one `requests` entry per operation, each a kebab-case `platform` and a short `intent` naming the operation alone (`"send a message to a channel"`, not the message). An optional `task` describes the whole job in general terms, which helps choose between similar actions. For each intent, One's decision model picks the action to use; the answer documents it, sets apart a substitute when the model was unsure, and lists a few alternatives. Large documents come back as a digest; call `find_one_actions` again with `load: [{ action_id, section }]` for a section it left out, `full: true` for the whole document, or `toc: true` for its table of contents. `ONE_ACTION_IDS`, `ONE_PERMISSIONS` and `ONE_CONNECTION_KEYS` apply to every answer: an action they refuse is never offered, and when they refuse the model's pick, the next allowed candidate takes its place.
|
|
70
|
+
|
|
71
|
+
Upgrading from 1.x: `search_one_platform_actions` and `get_one_action_knowledge` are removed. `find_one_actions` replaces both.
|
|
72
|
+
|
|
68
73
|
## Remote MCP Server
|
|
69
74
|
|
|
70
75
|
Prefer not to run anything locally? One hosts a remote MCP server at **`https://mcp.withone.ai/mcp`**. Point any MCP client that supports remote (HTTP) servers at that URL and authenticate with One via OAuth. There's no `npm install` and no `ONE_SECRET` to manage. You approve access in One's consent screen, where you can scope exactly which connections, actions, and permission levels the agent gets. Those choices are surfaced back to the agent through each connection's `access` field, so it knows what it can run without searching.
|
|
@@ -82,7 +87,7 @@ Add it to your client's MCP configuration. The endpoint speaks the Streamable HT
|
|
|
82
87
|
}
|
|
83
88
|
```
|
|
84
89
|
|
|
85
|
-
Some clients omit `type` and take the URL alone; UI-based clients (custom connectors) just need the URL itself. On first connect, your client opens a browser to authenticate and authorize with One. After that, the same
|
|
90
|
+
Some clients omit `type` and take the URL alone; UI-based clients (custom connectors) just need the URL itself. On first connect, your client opens a browser to authenticate and authorize with One. After that, the same three tools are available.
|
|
86
91
|
|
|
87
92
|
One-click install:
|
|
88
93
|
|
|
@@ -139,7 +144,7 @@ ONE_KNOWLEDGE_AGENT=true
|
|
|
139
144
|
| `ONE_PERMISSIONS` | `read` \| `write` \| `admin` | `admin` | Filter actions by HTTP method. `read` = GET only, `write` = GET/POST/PUT/PATCH, `admin` = all methods |
|
|
140
145
|
| `ONE_CONNECTION_KEYS` | `*` or comma-separated keys | `*` | Restrict visible connections and platforms to specific connection keys |
|
|
141
146
|
| `ONE_ACTION_IDS` | `*` or comma-separated IDs | `*` | Restrict visible and executable actions to specific action IDs |
|
|
142
|
-
| `ONE_KNOWLEDGE_AGENT` | `true` \| `false` | `false` | Remove the `execute_one_action` tool entirely, forcing knowledge-only mode. `
|
|
147
|
+
| `ONE_KNOWLEDGE_AGENT` | `true` \| `false` | `false` | Remove the `execute_one_action` tool entirely, forcing knowledge-only mode. `find_one_actions` then returns each chosen action's whole document with how to call it from code, and an Integration Code Guide (passthrough URL, `x-one-*` headers, `ONE_SECRET` / `ONE_{PLATFORM}_CONNECTION_KEY` env vars, backend-only placement) for writing application code |
|
|
143
148
|
|
|
144
149
|
All defaults preserve current behavior. If no access control env vars are set, the server starts with full access and all tools available.
|
|
145
150
|
|
package/build/client.js
CHANGED
|
@@ -184,47 +184,27 @@ export class OneClient {
|
|
|
184
184
|
}
|
|
185
185
|
}
|
|
186
186
|
/**
|
|
187
|
-
*
|
|
188
|
-
*
|
|
189
|
-
* @param
|
|
190
|
-
* @param
|
|
191
|
-
* @
|
|
192
|
-
*
|
|
187
|
+
* Finds the action for each intent, across platforms, in one call: search
|
|
188
|
+
* plus One's decision model, which picks among the candidates.
|
|
189
|
+
* @param requests - One entry per operation, `{ platform, intent }`
|
|
190
|
+
* @param task - The whole task in one line, which helps break ties
|
|
191
|
+
* @param knowledgeAgent - Search the catalog for writing integration code
|
|
192
|
+
* rather than the one for executing actions
|
|
193
|
+
* @returns One answer per intent, in the order asked
|
|
194
|
+
* @throws {Error} Naming core's reason when it refuses the request
|
|
193
195
|
*/
|
|
194
|
-
async
|
|
195
|
-
if (!platform?.trim()) {
|
|
196
|
-
throw new Error("Platform name is required");
|
|
197
|
-
}
|
|
198
|
-
if (!query?.trim()) {
|
|
199
|
-
throw new Error("Search query is required");
|
|
200
|
-
}
|
|
196
|
+
async findActions(requests, task, knowledgeAgent) {
|
|
201
197
|
try {
|
|
202
|
-
const
|
|
203
|
-
const url = `${this.baseUrl}/v1/available-actions/search/${platform}`;
|
|
204
|
-
// Default to knowledgeAgent if not specified
|
|
205
|
-
const isKnowledgeAgent = !agentType || agentType === "knowledge";
|
|
206
|
-
const params = {
|
|
207
|
-
query,
|
|
208
|
-
limit: '5'
|
|
209
|
-
};
|
|
210
|
-
if (isKnowledgeAgent) {
|
|
211
|
-
params.knowledgeAgent = 'true';
|
|
212
|
-
}
|
|
213
|
-
else {
|
|
214
|
-
params.executeAgent = 'true';
|
|
215
|
-
}
|
|
216
|
-
const response = await axios.get(url, {
|
|
217
|
-
headers,
|
|
218
|
-
params
|
|
219
|
-
});
|
|
198
|
+
const response = await axios.post(`${this.baseUrl}/v1/available-actions/find`, { requests, ...(task ? { task } : {}), knowledgeAgent }, { headers: this.generateHeaders() });
|
|
220
199
|
return response.data || [];
|
|
221
200
|
}
|
|
222
201
|
catch (error) {
|
|
223
|
-
console.error("Error
|
|
202
|
+
console.error("Error finding actions:", describeError(error));
|
|
224
203
|
if (axios.isAxiosError(error)) {
|
|
225
|
-
|
|
204
|
+
const reason = error.response?.data?.message;
|
|
205
|
+
throw new Error(`Failed to find actions: ${reason ?? `${error.response?.status} ${error.response?.statusText}`}`);
|
|
226
206
|
}
|
|
227
|
-
throw new Error("Failed to
|
|
207
|
+
throw new Error("Failed to find actions");
|
|
228
208
|
}
|
|
229
209
|
}
|
|
230
210
|
/**
|
|
@@ -269,31 +249,6 @@ export class OneClient {
|
|
|
269
249
|
throw new Error("Failed to fetch action details");
|
|
270
250
|
}
|
|
271
251
|
}
|
|
272
|
-
/**
|
|
273
|
-
* Gets knowledge for a specific action by ID
|
|
274
|
-
* @param actionId - The action ID to get knowledge for
|
|
275
|
-
* @returns The knowledge string for the action
|
|
276
|
-
* @throws {Error} If action ID is not provided or API request fails
|
|
277
|
-
*/
|
|
278
|
-
async getActionKnowledge(actionId) {
|
|
279
|
-
try {
|
|
280
|
-
const action = await this.getActionDetails(actionId);
|
|
281
|
-
if (!action.knowledge || !action.method) {
|
|
282
|
-
return {
|
|
283
|
-
knowledge: "No knowledge was found",
|
|
284
|
-
method: "No method was found"
|
|
285
|
-
};
|
|
286
|
-
}
|
|
287
|
-
return {
|
|
288
|
-
knowledge: action.knowledge,
|
|
289
|
-
method: action.method
|
|
290
|
-
};
|
|
291
|
-
}
|
|
292
|
-
catch (error) {
|
|
293
|
-
console.error("Error fetching action knowledge:", describeError(error));
|
|
294
|
-
throw error;
|
|
295
|
-
}
|
|
296
|
-
}
|
|
297
252
|
/**
|
|
298
253
|
* Resolves an allowlist of action ids to their metadata (title, method, and
|
|
299
254
|
* owning platform), so `list_one_integrations` can report each connection's
|
package/build/find.js
ADDED
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `find_one_actions` tool: find the action for every operation a task
|
|
3
|
+
* needs, across platforms, with its documentation, in one call.
|
|
4
|
+
*
|
|
5
|
+
* Core picks the actions (`POST /v1/available-actions/find`, search plus One's
|
|
6
|
+
* decision model). This module applies what only this server knows - its
|
|
7
|
+
* action allowlist, permission level and connection scope - and renders the
|
|
8
|
+
* answer in the same layout as the remote MCP's tool, documenting each action
|
|
9
|
+
* from its raw knowledge with this package's own digest engine, so a digest's
|
|
10
|
+
* notes name `find_one_actions` rather than a route the agent cannot call.
|
|
11
|
+
*
|
|
12
|
+
* @fileoverview find_one_actions logic, free of HTTP so it can be tested
|
|
13
|
+
* @author One
|
|
14
|
+
*/
|
|
15
|
+
import { buildKnowledgeResponse, isActionAllowed, isMethodAllowed, platformEnvSegment } from './helpers.js';
|
|
16
|
+
/** How many intents one find answers. Core enforces the same cap. */
|
|
17
|
+
export const FIND_MAX_INTENTS = 10;
|
|
18
|
+
/** How many documents one load returns. */
|
|
19
|
+
export const FIND_MAX_LOADS = 10;
|
|
20
|
+
/** Characters of documentation one answer carries in all, as core bounds its own (about 100k tokens). */
|
|
21
|
+
export const FIND_DOCUMENTS_BUDGET = 400_000;
|
|
22
|
+
/** What stands in for an action's documentation when it has none. */
|
|
23
|
+
export const NO_KNOWLEDGE = '(No additional knowledge available for this action.)';
|
|
24
|
+
/** How an agent that can execute runs what the documentation describes, with this server's real argument names. */
|
|
25
|
+
export const FIND_EXECUTE_HINT = 'Run it with `execute_one_action`: `platform`, `actionId`, `connectionKey` (from `list_one_integrations`), and the `data`, `pathVariables` and `queryParams` this document calls for.';
|
|
26
|
+
/** One wording for an action that does not exist and one this server may not use, so neither can be told apart. */
|
|
27
|
+
export const REFUSED_LOAD = 'no such action, or this server may not use it';
|
|
28
|
+
/** What a call that is neither a find nor a load is told. */
|
|
29
|
+
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
|
+
/**
|
|
34
|
+
* Answers a `find_one_actions` call: finds actions for `requests`, or serves
|
|
35
|
+
* more documentation for `load`.
|
|
36
|
+
* @throws {FindArgsError} When the call is neither, both, or `task` comes with `load`
|
|
37
|
+
*/
|
|
38
|
+
export async function findOneActions(args, deps, settings) {
|
|
39
|
+
if (args.requests && !args.load) {
|
|
40
|
+
return find(args.requests, args.task, deps, settings);
|
|
41
|
+
}
|
|
42
|
+
if (args.load && !args.requests && args.task === undefined) {
|
|
43
|
+
return load(args.load, deps, settings);
|
|
44
|
+
}
|
|
45
|
+
throw new FindArgsError(FIND_ARGS_ERROR);
|
|
46
|
+
}
|
|
47
|
+
/** An action as the agent refers to it: title, method, path and `actionId`. */
|
|
48
|
+
export function actionHeading(title, method, path, actionId) {
|
|
49
|
+
return `${title} · \`${method.toUpperCase()} ${path}\` · actionId: \`${actionId}\``;
|
|
50
|
+
}
|
|
51
|
+
function allows(settings, action) {
|
|
52
|
+
return isActionAllowed(action.systemId, settings.actionIds) && isMethodAllowed(action.method, settings.permissions);
|
|
53
|
+
}
|
|
54
|
+
function reaches(settings, platform) {
|
|
55
|
+
return settings.platforms === null || (platform !== undefined && settings.platforms.includes(platform));
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Drops every action this server's settings refuse. When that removes the
|
|
59
|
+
* pick itself, the runner-up, else the first allowed alternative, takes its
|
|
60
|
+
* place, so the intent still has something to use.
|
|
61
|
+
*/
|
|
62
|
+
function narrow(answer, settings) {
|
|
63
|
+
const allowed = (action) => allows(settings, action);
|
|
64
|
+
const selected = answer.selected.filter(allowed);
|
|
65
|
+
const alternatives = answer.alternatives.filter(allowed);
|
|
66
|
+
let runnerUp = answer.runnerUp && allowed(answer.runnerUp) ? answer.runnerUp : undefined;
|
|
67
|
+
let replaced = false;
|
|
68
|
+
// The model's pick is the first selected action; any after it are needed
|
|
69
|
+
// beside it. A refused pick is replaced even when a companion survives, so a
|
|
70
|
+
// companion is never presented as the confident pick.
|
|
71
|
+
if (answer.selected.length > 0 && !allowed(answer.selected[0])) {
|
|
72
|
+
const next = runnerUp ?? alternatives.shift();
|
|
73
|
+
if (next) {
|
|
74
|
+
selected.unshift(next);
|
|
75
|
+
runnerUp = next === runnerUp ? undefined : runnerUp;
|
|
76
|
+
}
|
|
77
|
+
replaced = selected.length > 0;
|
|
78
|
+
}
|
|
79
|
+
const alsoSelected = (answer.alsoSelected ?? []).filter(allowed);
|
|
80
|
+
const found = answer.selected.length + answer.alternatives.length + (answer.alsoSelected?.length ?? 0) + (answer.runnerUp ? 1 : 0);
|
|
81
|
+
const kept = selected.length + alternatives.length + alsoSelected.length + (runnerUp ? 1 : 0);
|
|
82
|
+
return {
|
|
83
|
+
answer: { ...answer, selected, alsoSelected, runnerUp, alternatives },
|
|
84
|
+
replaced,
|
|
85
|
+
refusedAll: found > 0 && kept === 0,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
/** Why the answer is what it is, as its opening line. */
|
|
89
|
+
function status(answer, narrowed) {
|
|
90
|
+
if (narrowed.refusedAll) {
|
|
91
|
+
return "Actions were found for this intent, but this server's settings (ONE_ACTION_IDS, ONE_PERMISSIONS) allow none of them, so rephrasing will not help. Tell the user which operation is blocked.";
|
|
92
|
+
}
|
|
93
|
+
if (narrowed.replaced) {
|
|
94
|
+
return 'The chosen action is not allowed on this server, so this is the next allowed candidate: check it fits before using it.';
|
|
95
|
+
}
|
|
96
|
+
if (answer.selector === 'none_fit' || answer.selected.length === 0) {
|
|
97
|
+
return answer.alternatives.length === 0
|
|
98
|
+
? 'No action fits this intent, and nothing close was found. Rephrase it, or check the platform name.'
|
|
99
|
+
: 'No action fits this intent. Rephrase it more specifically, or load one of the alternatives.';
|
|
100
|
+
}
|
|
101
|
+
if (answer.selector === 'search_order') {
|
|
102
|
+
return 'The decision model was unavailable, so this is the top search result: check it fits before using it.';
|
|
103
|
+
}
|
|
104
|
+
return answer.confidence === undefined
|
|
105
|
+
? 'Chosen by the decision model.'
|
|
106
|
+
: `Chosen with confidence ${answer.confidence.toFixed(2)}.`;
|
|
107
|
+
}
|
|
108
|
+
/** Actions listed one per line under `lead`, with how to load any of them, or nothing when there are none. */
|
|
109
|
+
function listing(lead, actions) {
|
|
110
|
+
if (actions.length === 0)
|
|
111
|
+
return [];
|
|
112
|
+
const listed = actions.map((a) => `- ${actionHeading(a.title, a.method, a.path, a.systemId)}`).join('\n');
|
|
113
|
+
return [`${lead} (load with \`find_one_actions\` and \`load: [{ action_id }]\`):\n${listed}`];
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* One action with its documentation: a digest whose notes name
|
|
117
|
+
* `find_one_actions` for an agent that executes, or the whole document with
|
|
118
|
+
* how to call it from code in knowledge mode, whose shared rules the answer
|
|
119
|
+
* adds once.
|
|
120
|
+
*/
|
|
121
|
+
function renderAction(doc, settings, request = {}) {
|
|
122
|
+
if (settings.knowledgeAgent) {
|
|
123
|
+
return codeGuideAction(doc, settings.baseUrl);
|
|
124
|
+
}
|
|
125
|
+
const heading = actionHeading(doc.title, doc.method, doc.path, doc.actionId);
|
|
126
|
+
if (doc.knowledge === undefined) {
|
|
127
|
+
return `### ${heading}\n\n${NO_KNOWLEDGE}`;
|
|
128
|
+
}
|
|
129
|
+
const body = buildKnowledgeResponse(doc.knowledge, doc.method, doc.platform, doc.actionId, {
|
|
130
|
+
section: request.section,
|
|
131
|
+
full: request.full,
|
|
132
|
+
toc: request.toc,
|
|
133
|
+
followUp: 'findTool',
|
|
134
|
+
}).text;
|
|
135
|
+
return `### ${heading}\n\n${body}`;
|
|
136
|
+
}
|
|
137
|
+
/** The sections joined, ending on what applies to every documented action - once, however many - or on nothing when nothing was documented. */
|
|
138
|
+
function finish(sections, documented, settings) {
|
|
139
|
+
const body = sections.join('\n\n---\n\n');
|
|
140
|
+
if (!documented)
|
|
141
|
+
return body;
|
|
142
|
+
return `${body}\n\n---\n\n${settings.knowledgeAgent ? codeGuideRules() : FIND_EXECUTE_HINT}`;
|
|
143
|
+
}
|
|
144
|
+
async function find(requests, task, deps, settings) {
|
|
145
|
+
if (requests.length === 0 || requests.length > FIND_MAX_INTENTS) {
|
|
146
|
+
throw new FindArgsError(`Send between 1 and ${FIND_MAX_INTENTS} requests.`);
|
|
147
|
+
}
|
|
148
|
+
const reachable = requests.filter((r) => reaches(settings, r.platform));
|
|
149
|
+
const answers = reachable.length > 0 ? await deps.findActions(reachable, task, settings.knowledgeAgent) : [];
|
|
150
|
+
let next = 0;
|
|
151
|
+
const plans = requests.map((intent) => {
|
|
152
|
+
if (!reaches(settings, intent.platform)) {
|
|
153
|
+
return { intent, unreachable: true, replaced: false, refusedAll: false, picks: [], deferred: [], runnerUpDocumented: false };
|
|
154
|
+
}
|
|
155
|
+
const { answer, replaced, refusedAll } = narrow(answers[next++], settings);
|
|
156
|
+
const companions = settings.knowledgeAgent ? answer.selected.slice(0, 1) : answer.selected;
|
|
157
|
+
return {
|
|
158
|
+
intent,
|
|
159
|
+
unreachable: false,
|
|
160
|
+
answer,
|
|
161
|
+
replaced,
|
|
162
|
+
refusedAll,
|
|
163
|
+
picks: companions,
|
|
164
|
+
deferred: [...answer.selected.slice(companions.length), ...(answer.alsoSelected ?? [])],
|
|
165
|
+
runnerUp: answer.runnerUp,
|
|
166
|
+
runnerUpDocumented: !settings.knowledgeAgent && answer.runnerUp !== undefined,
|
|
167
|
+
};
|
|
168
|
+
});
|
|
169
|
+
const documents = await fetchDocuments(plans, deps);
|
|
170
|
+
const rendered = boundDocuments(plans, documents, settings);
|
|
171
|
+
const sections = plans.map((plan, index) => {
|
|
172
|
+
if (plan.unreachable || !plan.answer) {
|
|
173
|
+
return `## ${plan.intent.platform}: ${plan.intent.intent}\n\nPlatform "${plan.intent.platform}" has no allowed connections on this server.`;
|
|
174
|
+
}
|
|
175
|
+
const { answer } = plan;
|
|
176
|
+
const parts = [`## ${plan.intent.platform}: ${plan.intent.intent}\n\n${status(answer, plan)}`];
|
|
177
|
+
const shown = rendered[index];
|
|
178
|
+
parts.push(...shown.picks);
|
|
179
|
+
const chosen = shown.picks.length === 0
|
|
180
|
+
? 'Chosen for this intent, documentation left out to keep the answer small'
|
|
181
|
+
: 'Also chosen for this intent, documentation left out to keep the answer small';
|
|
182
|
+
parts.push(...listing(chosen, shown.deferred));
|
|
183
|
+
if (plan.runnerUp) {
|
|
184
|
+
const lead = 'If the chosen action does not fit, use this one instead: one or the other, never both';
|
|
185
|
+
parts.push(...(shown.runnerUp ? [`${lead}.\n\n${shown.runnerUp}`] : listing(lead, [plan.runnerUp])));
|
|
186
|
+
}
|
|
187
|
+
parts.push(...listing('Alternatives, not documented', answer.alternatives));
|
|
188
|
+
return parts.join('\n\n');
|
|
189
|
+
});
|
|
190
|
+
return finish(sections, rendered.some((r) => r.picks.length > 0 || r.runnerUp !== undefined), settings);
|
|
191
|
+
}
|
|
192
|
+
/** Each documented action's raw knowledge, fetched once per action. */
|
|
193
|
+
async function fetchDocuments(plans, deps) {
|
|
194
|
+
const ids = new Set();
|
|
195
|
+
for (const plan of plans) {
|
|
196
|
+
plan.picks.forEach((a) => ids.add(a.systemId));
|
|
197
|
+
if (plan.runnerUp && plan.runnerUpDocumented)
|
|
198
|
+
ids.add(plan.runnerUp.systemId);
|
|
199
|
+
}
|
|
200
|
+
const entries = await Promise.all([...ids].map(async (id) => {
|
|
201
|
+
try {
|
|
202
|
+
return [id, (await deps.getActionDetails(id)).knowledge];
|
|
203
|
+
}
|
|
204
|
+
catch {
|
|
205
|
+
return [id, undefined];
|
|
206
|
+
}
|
|
207
|
+
}));
|
|
208
|
+
return new Map(entries);
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* Renders the documented actions within {@link FIND_DOCUMENTS_BUDGET}, picks
|
|
212
|
+
* before any runner-up. The first document is always served. Past the budget,
|
|
213
|
+
* an intent stops at its first pick that does not fit, listing it and the rest
|
|
214
|
+
* as chosen to load, rather than documenting a smaller one in its place; a
|
|
215
|
+
* runner-up that does not fit is listed.
|
|
216
|
+
*/
|
|
217
|
+
function boundDocuments(plans, documents, settings) {
|
|
218
|
+
let used = 0;
|
|
219
|
+
let first = true;
|
|
220
|
+
const admit = (text) => {
|
|
221
|
+
if (first || used + text.length <= FIND_DOCUMENTS_BUDGET) {
|
|
222
|
+
used += text.length;
|
|
223
|
+
first = false;
|
|
224
|
+
return true;
|
|
225
|
+
}
|
|
226
|
+
return false;
|
|
227
|
+
};
|
|
228
|
+
const toDocumented = (action, platform) => ({
|
|
229
|
+
title: action.title,
|
|
230
|
+
method: action.method,
|
|
231
|
+
path: action.path,
|
|
232
|
+
actionId: action.systemId,
|
|
233
|
+
platform,
|
|
234
|
+
tags: action.tags ?? [],
|
|
235
|
+
knowledge: documents.get(action.systemId),
|
|
236
|
+
});
|
|
237
|
+
const rendered = plans.map((plan) => {
|
|
238
|
+
const picks = [];
|
|
239
|
+
const deferred = [];
|
|
240
|
+
for (const [index, pick] of plan.picks.entries()) {
|
|
241
|
+
const text = renderAction(toDocumented(pick, plan.intent.platform), settings);
|
|
242
|
+
if (!admit(text)) {
|
|
243
|
+
deferred.push(...plan.picks.slice(index));
|
|
244
|
+
break;
|
|
245
|
+
}
|
|
246
|
+
picks.push(text);
|
|
247
|
+
}
|
|
248
|
+
return { picks, deferred: [...deferred, ...plan.deferred] };
|
|
249
|
+
});
|
|
250
|
+
plans.forEach((plan, index) => {
|
|
251
|
+
if (plan.runnerUp && plan.runnerUpDocumented) {
|
|
252
|
+
const text = renderAction(toDocumented(plan.runnerUp, plan.intent.platform), settings);
|
|
253
|
+
if (used + text.length <= FIND_DOCUMENTS_BUDGET) {
|
|
254
|
+
used += text.length;
|
|
255
|
+
rendered[index].runnerUp = text;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
});
|
|
259
|
+
return rendered;
|
|
260
|
+
}
|
|
261
|
+
async function load(loads, deps, settings) {
|
|
262
|
+
if (loads.length === 0 || loads.length > FIND_MAX_LOADS) {
|
|
263
|
+
throw new FindArgsError(`Send between 1 and ${FIND_MAX_LOADS} loads.`);
|
|
264
|
+
}
|
|
265
|
+
const entries = await Promise.all(loads.map(async (entry) => {
|
|
266
|
+
const refused = { text: `\`${entry.action_id}\`: ${REFUSED_LOAD}`, documented: false };
|
|
267
|
+
if (!isActionAllowed(entry.action_id, settings.actionIds))
|
|
268
|
+
return refused;
|
|
269
|
+
let details;
|
|
270
|
+
try {
|
|
271
|
+
details = await deps.getActionDetails(entry.action_id);
|
|
272
|
+
}
|
|
273
|
+
catch {
|
|
274
|
+
return refused;
|
|
275
|
+
}
|
|
276
|
+
if (!isMethodAllowed(details.method, settings.permissions) || !reaches(settings, details.connectionPlatform)) {
|
|
277
|
+
return refused;
|
|
278
|
+
}
|
|
279
|
+
const text = renderAction({
|
|
280
|
+
title: details.title,
|
|
281
|
+
method: details.method,
|
|
282
|
+
path: details.path,
|
|
283
|
+
actionId: entry.action_id,
|
|
284
|
+
platform: details.connectionPlatform ?? '',
|
|
285
|
+
tags: details.tags ?? [],
|
|
286
|
+
knowledge: details.knowledge,
|
|
287
|
+
}, settings, entry);
|
|
288
|
+
return { text, documented: true };
|
|
289
|
+
}));
|
|
290
|
+
let used = 0;
|
|
291
|
+
let served = false;
|
|
292
|
+
const bounded = entries.map((entry, index) => {
|
|
293
|
+
if (!entry.documented)
|
|
294
|
+
return entry;
|
|
295
|
+
if (served && used + entry.text.length > FIND_DOCUMENTS_BUDGET) {
|
|
296
|
+
return { text: `\`${loads[index].action_id}\`: too large to serve beside the others; load it on its own.`, documented: false };
|
|
297
|
+
}
|
|
298
|
+
used += entry.text.length;
|
|
299
|
+
served = true;
|
|
300
|
+
return entry;
|
|
301
|
+
});
|
|
302
|
+
return finish(bounded.map((e) => e.text), bounded.some((e) => e.documented), settings);
|
|
303
|
+
}
|
|
304
|
+
const CUSTOM_ACTION_NOTE = '\n\nThis is a custom action: ALSO include "connectionKey" (the same value as the x-one-connection-key header) as a field in the JSON body for non-GET requests.';
|
|
305
|
+
/**
|
|
306
|
+
* One action for knowledge mode: its whole document under its heading, then
|
|
307
|
+
* how to call it through the One Passthrough API. {@link codeGuideRules}
|
|
308
|
+
* holds what every action shares, so an answer about several states it once.
|
|
309
|
+
* Worded as the remote MCP's `code_guide_action`.
|
|
310
|
+
*/
|
|
311
|
+
export function codeGuideAction(doc, baseUrl) {
|
|
312
|
+
const apiBase = baseUrl.replace(/\/$/, '');
|
|
313
|
+
const method = doc.method.toUpperCase();
|
|
314
|
+
const path = doc.path.startsWith('/') ? doc.path : `/${doc.path}`;
|
|
315
|
+
const connEnv = `ONE_${platformEnvSegment(doc.platform)}_CONNECTION_KEY`;
|
|
316
|
+
const customNote = doc.tags.includes('custom') ? CUSTOM_ACTION_NOTE : '';
|
|
317
|
+
return `### ${actionHeading(doc.title, doc.method, doc.path, doc.actionId)}
|
|
318
|
+
**Platform:** ${doc.platform}
|
|
319
|
+
|
|
320
|
+
${doc.knowledge ?? NO_KNOWLEDGE}
|
|
321
|
+
|
|
322
|
+
## Calling this action from code
|
|
323
|
+
URL: ${apiBase}/v1/passthrough${path}
|
|
324
|
+
Method: ${method}
|
|
325
|
+
Headers:
|
|
326
|
+
- x-one-secret: the value of the ONE_SECRET env var
|
|
327
|
+
- x-one-connection-key: the value of the ${connEnv} env var
|
|
328
|
+
- x-one-action-id: ${doc.actionId}
|
|
329
|
+
- Content-Type: application/json${customNote}
|
|
330
|
+
|
|
331
|
+
Environment variables: ONE_SECRET (their One API key) and ${connEnv} (the key of their ${doc.platform} connection), both from the One dashboard.
|
|
332
|
+
|
|
333
|
+
\`\`\`typescript
|
|
334
|
+
const response = await fetch("${apiBase}/v1/passthrough${path}", {
|
|
335
|
+
method: "${method}",
|
|
336
|
+
headers: {
|
|
337
|
+
"x-one-secret": process.env.ONE_SECRET,
|
|
338
|
+
"x-one-connection-key": process.env.${connEnv},
|
|
339
|
+
"x-one-action-id": "${doc.actionId}",
|
|
340
|
+
"Content-Type": "application/json",
|
|
341
|
+
},
|
|
342
|
+
// body: JSON.stringify(...) - only for non-GET requests
|
|
343
|
+
});
|
|
344
|
+
\`\`\``;
|
|
345
|
+
}
|
|
346
|
+
/**
|
|
347
|
+
* The Integration Code Guide's rules, which hold for every action: where the
|
|
348
|
+
* code lives, that it goes through the One Passthrough API, where each
|
|
349
|
+
* parameter goes, the deployment the user must do, and how to write it.
|
|
350
|
+
* Worded as the remote MCP's `code_guide_rules`.
|
|
351
|
+
*/
|
|
352
|
+
export function codeGuideRules() {
|
|
353
|
+
return `================================================================
|
|
354
|
+
INTEGRATION CODE GUIDE - using these actions in an application
|
|
355
|
+
================================================================
|
|
356
|
+
You are in knowledge mode: actions cannot be executed here. Use this
|
|
357
|
+
guide, with each action's "Calling this action from code", to write
|
|
358
|
+
integration code in the user's project.
|
|
359
|
+
|
|
360
|
+
## 1. Where this code must live
|
|
361
|
+
Server-side only - an API route, edge function, or backend handler
|
|
362
|
+
(e.g. Supabase Edge Function, Next.js route handler, Express route).
|
|
363
|
+
NEVER call this API from browser/client code and NEVER hardcode secret
|
|
364
|
+
values in source - read them from environment variables.
|
|
365
|
+
|
|
366
|
+
## 2. The request
|
|
367
|
+
All calls go through the One Passthrough API. Do NOT call the third-party
|
|
368
|
+
API URL from the documentation directly. The URL is the One base plus the
|
|
369
|
+
action path ONLY:
|
|
370
|
+
✅ {One base}/v1/passthrough{action path} (the path starts with /)
|
|
371
|
+
❌ {One base}/v1/passthrough/https://some-vendor-api.com{action path}
|
|
372
|
+
|
|
373
|
+
## 3. Parameter placement
|
|
374
|
+
Per each action's documentation:
|
|
375
|
+
- Path variables (placeholders like {{userId}} in the path) → substitute
|
|
376
|
+
real values into the URL path; never send them in the body.
|
|
377
|
+
- Query parameters → the URL query string, not the body.
|
|
378
|
+
- Body fields → the JSON request body (POST/PUT/PATCH only).
|
|
379
|
+
|
|
380
|
+
## 4. Environment variables & deployment
|
|
381
|
+
The code will not work until the user sets each action's environment
|
|
382
|
+
variables in their hosting platform's secrets manager (Supabase secrets,
|
|
383
|
+
Vercel/Netlify environment settings, or the project's env settings -
|
|
384
|
+
never committed to code). When you deliver the code, explicitly tell the
|
|
385
|
+
user to set them.
|
|
386
|
+
|
|
387
|
+
## 5. Code generation rules
|
|
388
|
+
- Use TypeScript unless the user asked for another language.
|
|
389
|
+
- Include the complete input/output structure from the documentation
|
|
390
|
+
(required/optional fields, types) in the implementation.
|
|
391
|
+
- Handle errors: on a non-2xx response, read the response body and
|
|
392
|
+
surface a useful message.`;
|
|
393
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { describe, it } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { FIND_ARGS_ERROR, FIND_EXECUTE_HINT, FindArgsError, REFUSED_LOAD, findOneActions, } from './find.js';
|
|
4
|
+
// Pure: core and the knowledge endpoint are stubbed, so no network is touched.
|
|
5
|
+
const LARGE = [
|
|
6
|
+
'# Send Email',
|
|
7
|
+
'## Endpoint\nPOST /v1/gmail/send-email',
|
|
8
|
+
'## Request Body\n### Required Request Body Fields\n- to: string',
|
|
9
|
+
`## Response Fields\n${Array.from({ length: 400 }, (_, i) => `- field ${i}: a described response field`).join('\n')}`,
|
|
10
|
+
].join('\n\n');
|
|
11
|
+
const action = (id, method = 'POST', tags = []) => ({
|
|
12
|
+
systemId: id,
|
|
13
|
+
title: `Action ${id}`,
|
|
14
|
+
key: `key-${id}`,
|
|
15
|
+
method,
|
|
16
|
+
path: `/v1/${id}`,
|
|
17
|
+
tags,
|
|
18
|
+
knowledge: 'a digest core rendered for its own route',
|
|
19
|
+
});
|
|
20
|
+
const answer = (overrides = {}) => ({
|
|
21
|
+
platform: 'gmail',
|
|
22
|
+
intent: 'send an email',
|
|
23
|
+
selected: [action('a')],
|
|
24
|
+
alternatives: [action('b'), action('c', 'GET')],
|
|
25
|
+
selector: 'model',
|
|
26
|
+
confidence: 0.92,
|
|
27
|
+
...overrides,
|
|
28
|
+
});
|
|
29
|
+
const settings = (overrides = {}) => ({
|
|
30
|
+
knowledgeAgent: false,
|
|
31
|
+
permissions: 'admin',
|
|
32
|
+
actionIds: ['*'],
|
|
33
|
+
platforms: null,
|
|
34
|
+
baseUrl: 'https://api.withone.ai',
|
|
35
|
+
...overrides,
|
|
36
|
+
});
|
|
37
|
+
/** Core answering `answers` in turn, and raw knowledge per action. */
|
|
38
|
+
function deps(answers, knowledge = {}) {
|
|
39
|
+
const asked = [];
|
|
40
|
+
return {
|
|
41
|
+
asked,
|
|
42
|
+
async findActions(requests) {
|
|
43
|
+
asked.push(requests);
|
|
44
|
+
return answers.slice(0, requests.length);
|
|
45
|
+
},
|
|
46
|
+
async getActionDetails(id) {
|
|
47
|
+
if (id === 'missing')
|
|
48
|
+
throw new Error('not found');
|
|
49
|
+
return {
|
|
50
|
+
_id: id,
|
|
51
|
+
title: `Action ${id}`,
|
|
52
|
+
method: id === 'c' ? 'GET' : 'POST',
|
|
53
|
+
path: `/v1/${id}`,
|
|
54
|
+
tags: id === 'custom' ? ['custom'] : [],
|
|
55
|
+
knowledge: knowledge[id] ?? `# Action ${id}\n\nSmall doc for ${id}.`,
|
|
56
|
+
connectionPlatform: 'gmail',
|
|
57
|
+
};
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
const intent = [{ platform: 'gmail', intent: 'send an email' }];
|
|
62
|
+
describe('find_one_actions', () => {
|
|
63
|
+
it('takes requests or load, never both, never neither, and no task with a load', async () => {
|
|
64
|
+
for (const args of [{}, { requests: intent, load: [{ action_id: 'a' }] }, { load: [{ action_id: 'a' }], task: 't' }]) {
|
|
65
|
+
await assert.rejects(findOneActions(args, deps([answer()]), settings()), (e) => e instanceof FindArgsError && e.message === FIND_ARGS_ERROR);
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
it('names each action by actionId, documents the pick from its raw knowledge, and ends on the execute arguments once', async () => {
|
|
69
|
+
const text = await findOneActions({ requests: intent, ai_model: 'claude-sonnet-5' }, deps([answer()], { a: LARGE }), settings());
|
|
70
|
+
assert.ok(text.startsWith('## gmail: send an email\n\nChosen with confidence 0.92.'));
|
|
71
|
+
assert.ok(text.includes('### Action a · `POST /v1/a` · actionId: `a`'));
|
|
72
|
+
assert.ok(!text.includes('a digest core rendered for its own route'), "core's route digest is re-rendered, not relayed");
|
|
73
|
+
assert.ok(text.includes('find_one_actions with load: [{ action_id: "a"'), 'the digest sends the agent back to this tool');
|
|
74
|
+
assert.ok(!text.includes('get_one_action_knowledge') && !text.includes('/v1/available-actions/find/load'));
|
|
75
|
+
assert.ok(text.includes('Alternatives, not documented (load with `find_one_actions` and `load: [{ action_id }]`):\n- Action b'));
|
|
76
|
+
assert.equal(text.split(FIND_EXECUTE_HINT).length, 2, 'the execute hint closes the answer once');
|
|
77
|
+
});
|
|
78
|
+
it('sets the runner-up apart as a substitute', async () => {
|
|
79
|
+
const text = await findOneActions({ requests: intent }, deps([answer({ runnerUp: action('b'), alternatives: [] })]), settings());
|
|
80
|
+
assert.ok(text.includes('If the chosen action does not fit, use this one instead: one or the other, never both.\n\n### Action b'));
|
|
81
|
+
});
|
|
82
|
+
it('never offers an action this server refuses, promoting the next allowed one in place of a refused pick', async () => {
|
|
83
|
+
const text = await findOneActions({ requests: intent }, deps([answer({ selected: [action('a')], alternatives: [action('b'), action('c', 'GET')] })]), settings({ permissions: 'read' }));
|
|
84
|
+
assert.ok(text.includes('The chosen action is not allowed on this server, so this is the next allowed candidate'));
|
|
85
|
+
assert.ok(text.includes('### Action c · `GET /v1/c`'));
|
|
86
|
+
assert.ok(!text.includes('actionId: `a`') && !text.includes('actionId: `b`'), 'refused actions are not even listed');
|
|
87
|
+
});
|
|
88
|
+
it('replaces a refused pick even when a companion survives, so the companion is never shown as the pick', async () => {
|
|
89
|
+
const text = await findOneActions({ requests: intent }, deps([answer({ selected: [action('a'), action('c', 'GET')], runnerUp: action('d', 'GET'), alternatives: [] })]), settings({ permissions: 'read' }));
|
|
90
|
+
assert.ok(text.includes('The chosen action is not allowed on this server, so this is the next allowed candidate'));
|
|
91
|
+
assert.ok(text.indexOf('actionId: `d`') < text.indexOf('actionId: `c`'), 'the runner-up stands in first, the companion after it');
|
|
92
|
+
assert.ok(!text.includes('use this one instead'), 'the promoted runner-up is not also offered as a substitute');
|
|
93
|
+
});
|
|
94
|
+
it('serves the first document a load actually returns, however large, even after a refused entry', async () => {
|
|
95
|
+
const huge = `# Huge\n\n${'x'.repeat(450_000)}`;
|
|
96
|
+
const text = await findOneActions({ load: [{ action_id: 'missing' }, { action_id: 'a', full: true }] }, deps([], { a: huge }), settings());
|
|
97
|
+
assert.ok(text.includes(huge), 'the only served document is not refused as too large');
|
|
98
|
+
});
|
|
99
|
+
it('says the settings are why when they refuse every action found, rather than asking for a rephrase', async () => {
|
|
100
|
+
const text = await findOneActions({ requests: intent }, deps([answer({ selected: [action('a')], alternatives: [action('b')] })]), settings({ permissions: 'read' }));
|
|
101
|
+
assert.ok(text.includes("this server's settings (ONE_ACTION_IDS, ONE_PERMISSIONS) allow none of them"));
|
|
102
|
+
assert.ok(!text.includes('Rephrase it'));
|
|
103
|
+
});
|
|
104
|
+
it('answers a platform no allowed connection reaches without asking core about it', async () => {
|
|
105
|
+
const core = deps([answer({ platform: 'slack', intent: 'post a message' })]);
|
|
106
|
+
const text = await findOneActions({ requests: [intent[0], { platform: 'slack', intent: 'post a message' }] }, core, settings({ platforms: ['slack'] }));
|
|
107
|
+
assert.deepEqual(core.asked, [[{ platform: 'slack', intent: 'post a message' }]]);
|
|
108
|
+
assert.ok(text.includes('## gmail: send an email\n\nPlatform "gmail" has no allowed connections on this server.'));
|
|
109
|
+
assert.ok(text.includes('## slack: post a message'));
|
|
110
|
+
});
|
|
111
|
+
it('in knowledge mode documents the pick whole with how to call it from code, and states the rules once', async () => {
|
|
112
|
+
const text = await findOneActions({ requests: intent }, deps([answer({ selected: [action('custom', 'POST', ['custom']), action('b')] })], { custom: LARGE }), settings({ knowledgeAgent: true }));
|
|
113
|
+
assert.ok(text.includes(LARGE), 'the whole document, not a digest');
|
|
114
|
+
assert.ok(text.includes('### Action custom · `POST /v1/custom` · actionId: `custom`\n**Platform:** gmail'));
|
|
115
|
+
assert.ok(text.includes('x-one-action-id: custom'));
|
|
116
|
+
assert.ok(text.includes('This is a custom action'));
|
|
117
|
+
assert.ok(text.includes('Also chosen for this intent, documentation left out to keep the answer small'), 'whole documents are large, so only the pick is documented');
|
|
118
|
+
assert.equal(text.split('INTEGRATION CODE GUIDE').length, 2);
|
|
119
|
+
assert.ok(!text.includes('execute_one_action'));
|
|
120
|
+
});
|
|
121
|
+
it('says why when nothing fits, and adds no closing hint when nothing was documented', async () => {
|
|
122
|
+
const text = await findOneActions({ requests: intent }, deps([answer({ selected: [], alternatives: [], selector: 'none_fit' })]), settings());
|
|
123
|
+
assert.ok(text.includes('No action fits this intent, and nothing close was found.'));
|
|
124
|
+
assert.ok(!text.includes(FIND_EXECUTE_HINT));
|
|
125
|
+
});
|
|
126
|
+
it('loads what each entry asked for, refusing one this server may not use in the words for a missing one', async () => {
|
|
127
|
+
const text = await findOneActions({ load: [{ action_id: 'a', section: 'Response Fields' }, { action_id: 'missing' }, { action_id: 'b' }] }, deps([], { a: LARGE }), settings({ actionIds: ['a', 'missing'] }));
|
|
128
|
+
assert.ok(text.includes('### Action a'));
|
|
129
|
+
assert.ok(text.includes('field 399'), 'the named section is served');
|
|
130
|
+
assert.ok(!text.includes('POST /v1/gmail/send-email'), 'and only that section');
|
|
131
|
+
assert.ok(text.includes(`\`missing\`: ${REFUSED_LOAD}`));
|
|
132
|
+
assert.ok(text.includes(`\`b\`: ${REFUSED_LOAD}`), 'an action outside ONE_ACTION_IDS reads as missing');
|
|
133
|
+
});
|
|
134
|
+
});
|
package/build/helpers.js
CHANGED
|
@@ -75,7 +75,10 @@ export async function fetchPaginatedData(baseUrl, headers, additionalParams) {
|
|
|
75
75
|
* @param method - The action's HTTP method (echoed into the envelope)
|
|
76
76
|
* @param platform - The kebab-case platform identifier (for the load-more hint)
|
|
77
77
|
* @param actionId - The action ID (for the load-more hint)
|
|
78
|
-
* @param options - `section`/`toc`/`full` select what to return
|
|
78
|
+
* @param options - `section`/`toc`/`full` select what to return; `followUp`
|
|
79
|
+
* names the tool a digest's notes send the agent to, `find_one_actions` unless
|
|
80
|
+
* set (the engine's `get_one_action_knowledge` wording is kept for its other
|
|
81
|
+
* surfaces)
|
|
79
82
|
*/
|
|
80
83
|
export function buildKnowledgeResponse(knowledge, method, platform, actionId, options = {}) {
|
|
81
84
|
const doc = parseSections(knowledge);
|
|
@@ -139,12 +142,13 @@ export function buildKnowledgeResponse(knowledge, method, platform, actionId, op
|
|
|
139
142
|
if (wantFull) {
|
|
140
143
|
return { text: knowledge, structured: { title: doc.title, method, truncated: false } };
|
|
141
144
|
}
|
|
142
|
-
const
|
|
145
|
+
const followUp = options.followUp ?? 'findTool';
|
|
146
|
+
const digest = buildDigest(doc, { load: { followUp, platform, actionId } });
|
|
143
147
|
if (!digest.truncated) {
|
|
144
148
|
return { text: knowledge, structured: { title: doc.title, method, truncated: false } };
|
|
145
149
|
}
|
|
146
150
|
const toc = omittedSections(digest);
|
|
147
|
-
const text = [renderDigestBanner(digest), digest.markdown, renderDigestNotice(digest, platform, actionId)]
|
|
151
|
+
const text = [renderDigestBanner(digest), digest.markdown, renderDigestNotice(digest, platform, actionId, followUp)]
|
|
148
152
|
.filter(Boolean)
|
|
149
153
|
.join('\n\n');
|
|
150
154
|
return {
|
|
@@ -173,93 +177,6 @@ export function platformEnvSegment(platform) {
|
|
|
173
177
|
.filter(Boolean)
|
|
174
178
|
.join("_");
|
|
175
179
|
}
|
|
176
|
-
const CUSTOM_ACTION_NOTE = `
|
|
177
|
-
- This is a custom action: ALSO include "connectionKey" (the same value as the
|
|
178
|
-
x-one-connection-key header) as a field in the JSON body for non-GET requests.`;
|
|
179
|
-
/**
|
|
180
|
-
* Builds the knowledge-mode response: the action's knowledge document followed
|
|
181
|
-
* by the Integration Code Guide for writing application code against the One
|
|
182
|
-
* Passthrough API (env vars, backend-only placement, headers, deployment).
|
|
183
|
-
* Used only when ONE_KNOWLEDGE_AGENT is enabled; normal mode keeps
|
|
184
|
-
* buildActionKnowledgeWithGuidance.
|
|
185
|
-
* @param details - Full action details from the knowledge endpoint
|
|
186
|
-
* @param platform - The kebab-case platform identifier from the tool args
|
|
187
|
-
* @param baseUrl - The One API base URL (e.g. https://api.withone.ai)
|
|
188
|
-
* @returns The knowledge text with the appended Integration Code Guide
|
|
189
|
-
*/
|
|
190
|
-
export function buildKnowledgeModeGuidance(details, platform, baseUrl) {
|
|
191
|
-
const apiBase = baseUrl.replace(/\/$/, "");
|
|
192
|
-
const knowledge = details.knowledge || "No knowledge was found";
|
|
193
|
-
const method = (details.method || "").toUpperCase();
|
|
194
|
-
const path = details.path.startsWith("/") ? details.path : `/${details.path}`;
|
|
195
|
-
const connEnv = `ONE_${platformEnvSegment(platform)}_CONNECTION_KEY`;
|
|
196
|
-
const customNote = details.tags?.includes("custom") ? CUSTOM_ACTION_NOTE : "";
|
|
197
|
-
return `${knowledge}
|
|
198
|
-
|
|
199
|
-
================================================================
|
|
200
|
-
INTEGRATION CODE GUIDE — using this action in an application
|
|
201
|
-
================================================================
|
|
202
|
-
You are in knowledge mode: this action cannot be executed here. Use this
|
|
203
|
-
guide to write integration code in the user's project.
|
|
204
|
-
|
|
205
|
-
## 1. Where this code must live
|
|
206
|
-
Server-side only — an API route, edge function, or backend handler
|
|
207
|
-
(e.g. Supabase Edge Function, Next.js route handler, Express route).
|
|
208
|
-
NEVER call this API from browser/client code and NEVER hardcode secret
|
|
209
|
-
values in source — read them from environment variables.
|
|
210
|
-
|
|
211
|
-
## 2. The request
|
|
212
|
-
All calls go through the One Passthrough API. Do NOT call the third-party
|
|
213
|
-
API URL from the documentation above directly.
|
|
214
|
-
|
|
215
|
-
URL: ${apiBase}/v1/passthrough${path}
|
|
216
|
-
Method: ${method}
|
|
217
|
-
Headers:
|
|
218
|
-
- x-one-secret: the value of the ONE_SECRET env var
|
|
219
|
-
- x-one-connection-key: the value of the ${connEnv} env var
|
|
220
|
-
- x-one-action-id: ${details._id}
|
|
221
|
-
- Content-Type: application/json
|
|
222
|
-
|
|
223
|
-
The URL is the One base plus the action path ONLY:
|
|
224
|
-
✅ ${apiBase}/v1/passthrough${path}
|
|
225
|
-
❌ ${apiBase}/v1/passthrough/https://some-vendor-api.com${path}
|
|
226
|
-
|
|
227
|
-
## 3. Parameter placement
|
|
228
|
-
Per the documentation above:
|
|
229
|
-
- Path variables (placeholders like {{userId}} in the path) → substitute
|
|
230
|
-
real values into the URL path; never send them in the body.
|
|
231
|
-
- Query parameters → the URL query string, not the body.
|
|
232
|
-
- Body fields → the JSON request body (POST/PUT/PATCH only).${customNote}
|
|
233
|
-
|
|
234
|
-
## 4. Environment variables & deployment
|
|
235
|
-
The code will not work until the user sets these in their hosting
|
|
236
|
-
platform's secrets manager (Supabase secrets, Vercel/Netlify environment
|
|
237
|
-
settings, or the project's env settings — never committed to code):
|
|
238
|
-
- ONE_SECRET → their One API key (from the One dashboard)
|
|
239
|
-
- ${connEnv} → the connection key for ${platform} (from list_one_integrations)
|
|
240
|
-
When you deliver the code, explicitly tell the user to set both.
|
|
241
|
-
|
|
242
|
-
## 5. Code generation rules
|
|
243
|
-
- Use TypeScript unless the user asked for another language.
|
|
244
|
-
- Include the complete input/output structure from the documentation
|
|
245
|
-
above (required/optional fields, types) in the implementation.
|
|
246
|
-
- Handle errors: on a non-2xx response, read the response body and
|
|
247
|
-
surface a useful message.
|
|
248
|
-
- Minimal example:
|
|
249
|
-
|
|
250
|
-
\`\`\`typescript
|
|
251
|
-
const response = await fetch("${apiBase}/v1/passthrough${path}", {
|
|
252
|
-
method: "${method}",
|
|
253
|
-
headers: {
|
|
254
|
-
"x-one-secret": process.env.ONE_SECRET,
|
|
255
|
-
"x-one-connection-key": process.env.${connEnv},
|
|
256
|
-
"x-one-action-id": "${details._id}",
|
|
257
|
-
"Content-Type": "application/json",
|
|
258
|
-
},
|
|
259
|
-
// body: JSON.stringify(...) — only for non-GET requests
|
|
260
|
-
});
|
|
261
|
-
\`\`\``;
|
|
262
|
-
}
|
|
263
180
|
/** Folds a path variable name for loose matching: `ticket_id`, `ticketId`, `TICKET-ID` all fold to `ticketid`. */
|
|
264
181
|
const foldVariableName = (name) => name.toLowerCase().replace(/[\s_-]/g, '');
|
|
265
182
|
/**
|
|
@@ -298,12 +215,6 @@ const PERMISSION_METHODS = {
|
|
|
298
215
|
write: ["GET", "POST", "PUT", "PATCH"],
|
|
299
216
|
admin: null,
|
|
300
217
|
};
|
|
301
|
-
export function filterByPermissions(actions, permissions) {
|
|
302
|
-
const allowed = PERMISSION_METHODS[permissions];
|
|
303
|
-
if (allowed === null)
|
|
304
|
-
return actions;
|
|
305
|
-
return actions.filter((a) => allowed.includes(a.method.toUpperCase()));
|
|
306
|
-
}
|
|
307
218
|
export function isMethodAllowed(method, permissions) {
|
|
308
219
|
const allowed = PERMISSION_METHODS[permissions];
|
|
309
220
|
if (allowed === null)
|
|
@@ -368,12 +279,3 @@ export function buildIntegrationsResponse(connections, access, available) {
|
|
|
368
279
|
}
|
|
369
280
|
return response;
|
|
370
281
|
}
|
|
371
|
-
/** Back office tag for actions agents must not use (a `custom` action covers them). */
|
|
372
|
-
export const HIDDEN_FROM_AGENTS_TAG = "hidden:agents";
|
|
373
|
-
/**
|
|
374
|
-
* Whether an action is hidden from executing agents. Knowledge/code-gen mode
|
|
375
|
-
* still sees these: writing integration code may target the raw endpoint.
|
|
376
|
-
*/
|
|
377
|
-
export function isHiddenFromAgents(tags) {
|
|
378
|
-
return (tags ?? []).includes(HIDDEN_FROM_AGENTS_TAG);
|
|
379
|
-
}
|
package/build/index.js
CHANGED
|
@@ -11,8 +11,9 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
|
11
11
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
12
12
|
import { ErrorCode, McpError, } from '@modelcontextprotocol/sdk/types.js';
|
|
13
13
|
import { OneClient } from './client.js';
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
14
|
+
import { isMethodAllowed, isActionAllowed, buildIntegrationsResponse, } from './helpers.js';
|
|
15
|
+
import { FindArgsError, findOneActions } from './find.js';
|
|
16
|
+
import { listOneIntegrationsToolConfig, findOneActionsToolConfig, executeOneActionToolConfig } from './schemas.js';
|
|
16
17
|
const SERVER_NAME = "one-mcp-server";
|
|
17
18
|
const SERVER_VERSION = "1.0.0";
|
|
18
19
|
const ONE_SECRET = process.env.ONE_SECRET;
|
|
@@ -74,13 +75,9 @@ server.registerTool("list_one_integrations", listOneIntegrationsToolConfig, asyn
|
|
|
74
75
|
await initializeOne();
|
|
75
76
|
return await handleGetIntegrations(args);
|
|
76
77
|
});
|
|
77
|
-
server.registerTool("
|
|
78
|
+
server.registerTool("find_one_actions", findOneActionsToolConfig, async (args) => {
|
|
78
79
|
await initializeOne();
|
|
79
|
-
return await
|
|
80
|
-
});
|
|
81
|
-
server.registerTool("get_one_action_knowledge", getOneActionKnowledgeToolConfig, async (args) => {
|
|
82
|
-
await initializeOne();
|
|
83
|
-
return await handleGetActionKnowledge(args);
|
|
80
|
+
return await handleFindOneActions(args);
|
|
84
81
|
});
|
|
85
82
|
if (!ONE_KNOWLEDGE_AGENT) {
|
|
86
83
|
server.registerTool("execute_one_action", executeOneActionToolConfig, async (args) => {
|
|
@@ -116,127 +113,36 @@ async function handleGetIntegrations(args) {
|
|
|
116
113
|
throw new McpError(ErrorCode.InternalError, `Failed to retrieve integrations: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
|
117
114
|
}
|
|
118
115
|
}
|
|
119
|
-
async function
|
|
116
|
+
async function handleFindOneActions(args) {
|
|
120
117
|
try {
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
const cleanedActions = actions.map(action => ({
|
|
131
|
-
actionId: action.systemId,
|
|
132
|
-
title: action.title,
|
|
133
|
-
method: action.method,
|
|
134
|
-
path: action.path
|
|
135
|
-
}));
|
|
136
|
-
// Handle empty results with helpful suggestions
|
|
137
|
-
if (cleanedActions.length === 0) {
|
|
138
|
-
const suggestionsText = `No actions found for platform '${args.platform}' matching query '${args.query}'.
|
|
139
|
-
|
|
140
|
-
SUGGESTIONS:
|
|
141
|
-
- Try a more general query (e.g., 'list', 'get', 'search', 'create')
|
|
142
|
-
- Verify the platform name is correct
|
|
143
|
-
- Check that actions exist for this platform using list_one_integrations
|
|
144
|
-
|
|
145
|
-
EXAMPLES OF GOOD QUERIES:
|
|
146
|
-
- "search contacts"
|
|
147
|
-
- "send email"
|
|
148
|
-
- "create customer"
|
|
149
|
-
- "list orders"`;
|
|
150
|
-
return {
|
|
151
|
-
content: [
|
|
152
|
-
{
|
|
153
|
-
type: "text",
|
|
154
|
-
text: suggestionsText,
|
|
155
|
-
},
|
|
156
|
-
],
|
|
157
|
-
};
|
|
158
|
-
}
|
|
159
|
-
// Build structured response
|
|
160
|
-
const structuredResponse = {
|
|
161
|
-
actions: cleanedActions,
|
|
162
|
-
metadata: {
|
|
163
|
-
platform: args.platform,
|
|
164
|
-
query: args.query,
|
|
165
|
-
count: cleanedActions.length
|
|
166
|
-
}
|
|
167
|
-
};
|
|
168
|
-
const responseText = `Found ${cleanedActions.length} action(s) for platform '${args.platform}' matching query '${args.query}':
|
|
169
|
-
|
|
170
|
-
${JSON.stringify(structuredResponse, null, 2)}
|
|
171
|
-
|
|
172
|
-
NEXT STEP: Use get_one_action_knowledge with an actionId to get detailed documentation before building requests or executing actions.`;
|
|
118
|
+
const text = await findOneActions(args, oneClient, {
|
|
119
|
+
knowledgeAgent: ONE_KNOWLEDGE_AGENT,
|
|
120
|
+
permissions: ONE_PERMISSIONS,
|
|
121
|
+
actionIds: ONE_ACTION_IDS,
|
|
122
|
+
platforms: ONE_CONNECTION_KEYS.includes("*")
|
|
123
|
+
? null
|
|
124
|
+
: oneClient.getUserConnections().map(c => c.platform),
|
|
125
|
+
baseUrl: oneClient.getBaseUrl(),
|
|
126
|
+
});
|
|
173
127
|
return {
|
|
174
128
|
content: [
|
|
175
129
|
{
|
|
176
130
|
type: "text",
|
|
177
|
-
text
|
|
131
|
+
text,
|
|
178
132
|
},
|
|
179
133
|
],
|
|
180
|
-
structuredContent: structuredResponse,
|
|
181
134
|
};
|
|
182
135
|
}
|
|
183
136
|
catch (error) {
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
async function handleGetActionKnowledge(args) {
|
|
188
|
-
try {
|
|
189
|
-
const actionId = args.actionId;
|
|
190
|
-
if (!isActionAllowed(actionId, ONE_ACTION_IDS)) {
|
|
191
|
-
throw new McpError(ErrorCode.InvalidRequest, `Action "${actionId}" is not in the allowed action list`);
|
|
192
|
-
}
|
|
193
|
-
if (!ONE_CONNECTION_KEYS.includes("*")) {
|
|
194
|
-
const connectedPlatforms = oneClient.getUserConnections().map(c => c.platform);
|
|
195
|
-
if (!connectedPlatforms.includes(args.platform)) {
|
|
196
|
-
throw new McpError(ErrorCode.InvalidRequest, `Platform "${args.platform}" has no allowed connections`);
|
|
197
|
-
}
|
|
198
|
-
}
|
|
199
|
-
if (ONE_KNOWLEDGE_AGENT) {
|
|
200
|
-
// Knowledge/code-gen mode returns the full document: the appended
|
|
201
|
-
// Integration Code Guide tells the agent to reproduce the complete
|
|
202
|
-
// input and output structure, which the digest would defer. Digesting
|
|
203
|
-
// is for the execute flow below, where the agent only needs to build
|
|
204
|
-
// one request.
|
|
205
|
-
const details = await oneClient.getActionDetails(actionId);
|
|
206
|
-
const knowledgeWithGuide = buildKnowledgeModeGuidance(details, args.platform, oneClient.getBaseUrl());
|
|
137
|
+
// A call the agent has to fix goes back to it as a tool error it can read
|
|
138
|
+
// and correct, rather than a protocol failure.
|
|
139
|
+
if (error instanceof FindArgsError) {
|
|
207
140
|
return {
|
|
208
|
-
content: [
|
|
209
|
-
|
|
210
|
-
type: "text",
|
|
211
|
-
text: knowledgeWithGuide,
|
|
212
|
-
},
|
|
213
|
-
],
|
|
141
|
+
content: [{ type: "text", text: error.message }],
|
|
142
|
+
isError: true,
|
|
214
143
|
};
|
|
215
144
|
}
|
|
216
|
-
|
|
217
|
-
throw new McpError(ErrorCode.InvalidRequest, `Action "${actionId}" is hidden from agents; search for the task again to find the action to use instead`);
|
|
218
|
-
}
|
|
219
|
-
const { knowledge, method } = await oneClient.getActionKnowledge(actionId);
|
|
220
|
-
const response = buildKnowledgeResponse(knowledge, method, args.platform, actionId, {
|
|
221
|
-
section: args.section,
|
|
222
|
-
full: args.full,
|
|
223
|
-
toc: args.toc,
|
|
224
|
-
});
|
|
225
|
-
// Execute mode returns the documentation alone: execute_one_action builds
|
|
226
|
-
// the passthrough request itself, so the raw-HTTP guidance code-gen mode
|
|
227
|
-
// appends (base URL, x-one-* headers, env var names) would only add tokens.
|
|
228
|
-
return {
|
|
229
|
-
content: [
|
|
230
|
-
{
|
|
231
|
-
type: "text",
|
|
232
|
-
text: response.text,
|
|
233
|
-
},
|
|
234
|
-
],
|
|
235
|
-
structuredContent: response.structured,
|
|
236
|
-
};
|
|
237
|
-
}
|
|
238
|
-
catch (error) {
|
|
239
|
-
throw new McpError(ErrorCode.InternalError, `Failed to retrieve action knowledge: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
|
145
|
+
throw new McpError(ErrorCode.InternalError, `Failed to find actions: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
|
240
146
|
}
|
|
241
147
|
}
|
|
242
148
|
async function handleExecuteOneAction(args) {
|
|
@@ -251,9 +157,6 @@ async function handleExecuteOneAction(args) {
|
|
|
251
157
|
}
|
|
252
158
|
}
|
|
253
159
|
const actionDetails = await oneClient.getActionDetails(args.actionId);
|
|
254
|
-
if (isHiddenFromAgents(actionDetails.tags)) {
|
|
255
|
-
throw new McpError(ErrorCode.InvalidRequest, `Action "${args.actionId}" is hidden from agents; search for the task again to find the action to use instead`);
|
|
256
|
-
}
|
|
257
160
|
if (!isMethodAllowed(actionDetails.method, ONE_PERMISSIONS)) {
|
|
258
161
|
throw new McpError(ErrorCode.InvalidRequest, `Method "${actionDetails.method}" is not allowed under "${ONE_PERMISSIONS}" permission level`);
|
|
259
162
|
}
|
|
@@ -22,7 +22,8 @@ describe('buildKnowledgeResponse', () => {
|
|
|
22
22
|
assert.ok(r.structured.omitted >= 1);
|
|
23
23
|
assert.ok(Array.isArray(r.structured.sections) && r.structured.sections.length >= 1);
|
|
24
24
|
assert.ok(r.text.includes('**Digest.**'));
|
|
25
|
-
assert.ok(r.text.includes('
|
|
25
|
+
assert.ok(r.text.includes('find_one_actions with load: [{ action_id: "act_1"'));
|
|
26
|
+
assert.ok(!r.text.includes('get_one_action_knowledge'), 'this server has no knowledge tool to send the agent to');
|
|
26
27
|
// A deferred body is not inlined into the digest text.
|
|
27
28
|
assert.ok(!r.text.includes('Response Fields field 100'));
|
|
28
29
|
// No CLI-flag phrasing leaks into agent-visible text.
|
|
@@ -17,6 +17,13 @@
|
|
|
17
17
|
*
|
|
18
18
|
* Everything in this module is pure: string in, structure out. No I/O.
|
|
19
19
|
*/
|
|
20
|
+
/** How to load section `id` in full, closing a cut section. */
|
|
21
|
+
function loadSection(load, id) {
|
|
22
|
+
if (load?.followUp === 'findTool') {
|
|
23
|
+
return `calling find_one_actions with load: [{ action_id: "${load.actionId}", section: "${id}" }]`;
|
|
24
|
+
}
|
|
25
|
+
return `calling get_one_action_knowledge again with section: "${id}"`;
|
|
26
|
+
}
|
|
20
27
|
export const DEFAULT_WHOLE_DOC_THRESHOLD = 8_000;
|
|
21
28
|
export const DEFAULT_DIGEST_BUDGET = 8_000;
|
|
22
29
|
export const DEFAULT_SECTION_CAP = 6_000;
|
|
@@ -204,13 +211,13 @@ function deferredPriority(heading) {
|
|
|
204
211
|
return 6;
|
|
205
212
|
return 4;
|
|
206
213
|
}
|
|
207
|
-
function truncateText(text, cap, id) {
|
|
214
|
+
function truncateText(text, cap, id, load) {
|
|
208
215
|
const cut = text.slice(0, cap);
|
|
209
216
|
// Back up to a line boundary so we never split a table row or code fence mid-way.
|
|
210
217
|
const nl = cut.lastIndexOf('\n');
|
|
211
218
|
const head = nl > cap * 0.5 ? cut.slice(0, nl) : cut;
|
|
212
219
|
const remaining = text.length - head.length;
|
|
213
|
-
return `${head}\n\n_[truncated — ${remaining.toLocaleString('en-US')} more chars. Load the full section by
|
|
220
|
+
return `${head}\n\n_[truncated — ${remaining.toLocaleString('en-US')} more chars. Load the full section by ${loadSection(load, id)}]_`;
|
|
214
221
|
}
|
|
215
222
|
/**
|
|
216
223
|
* Build the digest: essential sections in full (capped), then deferred
|
|
@@ -247,7 +254,7 @@ export function buildDigest(doc, opts = {}) {
|
|
|
247
254
|
if (node.tier === 'essential' || (node.level >= 3 && hasEssentialDescendant(node))) {
|
|
248
255
|
const own = node.text;
|
|
249
256
|
if (own.length > cap) {
|
|
250
|
-
const render = truncateText(own, cap, node.id);
|
|
257
|
+
const render = truncateText(own, cap, node.id, opts.load);
|
|
251
258
|
decisions.set(node.id, { node, included: 'partial', render });
|
|
252
259
|
used += render.length;
|
|
253
260
|
}
|
|
@@ -398,7 +405,7 @@ export function omittedSections(digest, max = DEFAULT_TOC_MAX_ENTRIES) {
|
|
|
398
405
|
* The trailer appended to a digest so an agent that only reads the markdown
|
|
399
406
|
* (and never looks at the JSON envelope) still knows the document continues.
|
|
400
407
|
*/
|
|
401
|
-
export function renderDigestNotice(digest, platform, actionId) {
|
|
408
|
+
export function renderDigestNotice(digest, platform, actionId, followUp = 'knowledge') {
|
|
402
409
|
if (!digest.truncated)
|
|
403
410
|
return '';
|
|
404
411
|
// List only the top-most omitted sections: in document order the nearest
|
|
@@ -433,8 +440,15 @@ export function renderDigestNotice(digest, platform, actionId) {
|
|
|
433
440
|
return [
|
|
434
441
|
'---',
|
|
435
442
|
`**This is a digest, not the full document.** ${topMost.length} section${topMost.length === 1 ? '' : 's'} omitted (${digest.omittedChars.toLocaleString('en-US')} chars): ${nameList}.`,
|
|
436
|
-
|
|
437
|
-
|
|
443
|
+
...(followUp === 'findTool'
|
|
444
|
+
? [
|
|
445
|
+
`Load one or more by calling find_one_actions with load: [{ action_id: "${actionId}", section: "${topMost[0]?.heading ?? 'Response'}" }] (comma-separate several).`,
|
|
446
|
+
`Load the whole document with full: true in place of section.`,
|
|
447
|
+
]
|
|
448
|
+
: [
|
|
449
|
+
`Load one or more by calling get_one_action_knowledge again for ${platform} / ${actionId} with section: "${topMost[0]?.heading ?? 'Response'}" (comma-separate several).`,
|
|
450
|
+
`Load the whole document with full: true.`,
|
|
451
|
+
]),
|
|
438
452
|
].join('\n');
|
|
439
453
|
}
|
|
440
454
|
/**
|
|
@@ -224,6 +224,28 @@ describe('renderDigestNotice', () => {
|
|
|
224
224
|
assert.ok(!notice.includes('Success Response (201 Created)'), 'children of an omitted parent are implied');
|
|
225
225
|
assert.ok(notice.includes('Response Fields'));
|
|
226
226
|
});
|
|
227
|
+
// Byte-for-byte the Rust engine's `KnowledgeFollowUp::FindTool` wording, so
|
|
228
|
+
// an agent reads the same call from the remote MCP and from this package.
|
|
229
|
+
it('names find_one_actions with the exact load call for the find tool', () => {
|
|
230
|
+
const d = buildDigest(parseSections(GITHUB), {
|
|
231
|
+
wholeDocThreshold: 0,
|
|
232
|
+
budget: 0,
|
|
233
|
+
load: { followUp: 'findTool', platform: 'github', actionId: 'conn_mod_def::X::Y' },
|
|
234
|
+
});
|
|
235
|
+
const notice = renderDigestNotice(d, 'github', 'conn_mod_def::X::Y', 'findTool');
|
|
236
|
+
const [, , load, whole] = notice.split('\n');
|
|
237
|
+
assert.match(load, /^Load one or more by calling find_one_actions with load: \[\{ action_id: "conn_mod_def::X::Y", section: "[^"]+" \}\] \(comma-separate several\)\.$/);
|
|
238
|
+
assert.equal(whole, 'Load the whole document with full: true in place of section.');
|
|
239
|
+
assert.ok(!notice.includes('get_one_action_knowledge'));
|
|
240
|
+
});
|
|
241
|
+
it('names find_one_actions in a cut section\'s note for the find tool', () => {
|
|
242
|
+
const body = `# Upload\n\n## Request Body\n\n${'field: value\n'.repeat(1_000)}\n## Response Fields\n\nid`;
|
|
243
|
+
const d = buildDigest(parseSections(body), {
|
|
244
|
+
wholeDocThreshold: 0,
|
|
245
|
+
load: { followUp: 'findTool', platform: 'gmail', actionId: 'gmail::upload' },
|
|
246
|
+
});
|
|
247
|
+
assert.ok(d.markdown.includes('more chars. Load the full section by calling find_one_actions with load: [{ action_id: "gmail::upload", section: "request-body" }]]_'), d.markdown.slice(-300));
|
|
248
|
+
});
|
|
227
249
|
});
|
|
228
250
|
describe('renderDigestBanner', () => {
|
|
229
251
|
it('is empty when nothing was omitted and one line otherwise', () => {
|
package/build/schemas.js
CHANGED
|
@@ -13,29 +13,29 @@ import { z } from 'zod';
|
|
|
13
13
|
*/
|
|
14
14
|
export const listOneIntegrationsInputSchema = {};
|
|
15
15
|
/**
|
|
16
|
-
* Schema for
|
|
16
|
+
* Schema for finding actions and their documentation. Worded as the remote
|
|
17
|
+
* MCP's `find_one_actions`, so an agent reads one tool on either surface.
|
|
17
18
|
*/
|
|
18
|
-
export const
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
toc: z.boolean().optional().describe("Return only the table of contents (every section's id, heading, and size), no bodies. Use it to see everything available when the digest collapsed its list. Ignored when `section` is set.")
|
|
19
|
+
export const findOneActionsInputSchema = {
|
|
20
|
+
requests: z.array(z.object({
|
|
21
|
+
platform: z.string().describe('The kebab-case platform identifier (e.g. "gmail", "stripe").'),
|
|
22
|
+
intent: z.string().describe('The operation alone, in a few words (e.g. "send a message to a channel"), without its data: no IDs, names or message text.')
|
|
23
|
+
})).min(1).max(10).optional().describe('What to do, one entry per API operation, on any platforms (1 to 10). Each is the kebab-case platform (e.g. "gmail", "hubspot") and a short intent (e.g. "send an email", "find a contact by email").'),
|
|
24
|
+
task: z.string().optional().describe('The whole task in one line, in general terms (e.g. "email a report to a contact"): what it does, without names, addresses, IDs or message text. It helps choose between similar actions. Never the chat history. Only with `requests`.'),
|
|
25
|
+
load: z.array(z.object({
|
|
26
|
+
action_id: z.string().describe("The actionId from a find_one_actions answer."),
|
|
27
|
+
section: z.string().optional().describe('Section name(s) to load, comma-separated (e.g. "Response Fields"). Omit, with `full` and `toc` unset, for the digest. In knowledge mode every load returns the whole document, so `section` and `toc` do not narrow it.'),
|
|
28
|
+
full: z.boolean().optional().describe("Load the whole document. Ignored when `section` is set."),
|
|
29
|
+
toc: z.boolean().optional().describe("Load only the table of contents. Ignored when `section` is set.")
|
|
30
|
+
})).min(1).max(10).optional().describe("More documentation for actions already found (1 to 10): a section the digest omitted, the whole document, the table of contents, or an alternative's documentation. Use instead of `requests`."),
|
|
31
|
+
ai_model: z.string().optional().describe('The AI model you are running as (e.g. "claude-sonnet-5", "gpt-5"), which helps optimize the documentation returned. Optional.')
|
|
32
32
|
};
|
|
33
33
|
/**
|
|
34
34
|
* Schema for executing One actions
|
|
35
35
|
*/
|
|
36
36
|
export const executeOneActionInputSchema = {
|
|
37
37
|
platform: z.string().describe("Platform name"),
|
|
38
|
-
actionId: z.string().describe("
|
|
38
|
+
actionId: z.string().describe("The actionId from find_one_actions"),
|
|
39
39
|
connectionKey: z.string().describe("Key of the connection to use"),
|
|
40
40
|
data: z.any().optional().describe("Request data (for POST, PUT, etc.)"),
|
|
41
41
|
pathVariables: z.record(z.union([z.string(), z.number(), z.boolean()])).optional().describe("Variables to replace in the path"),
|
|
@@ -81,22 +81,6 @@ export const listOneIntegrationsOutputSchema = {
|
|
|
81
81
|
availableCount: z.number().optional()
|
|
82
82
|
}).describe("Counts of connections (and available platforms in knowledge/code-gen mode)")
|
|
83
83
|
};
|
|
84
|
-
/**
|
|
85
|
-
* Output schemas for search_one_platform_actions tool
|
|
86
|
-
*/
|
|
87
|
-
export const searchOnePlatformActionsOutputSchema = {
|
|
88
|
-
actions: z.array(z.object({
|
|
89
|
-
actionId: z.string().describe("Unique identifier for the action"),
|
|
90
|
-
title: z.string().describe("Human-readable action name"),
|
|
91
|
-
method: z.string().describe("HTTP method (GET, POST, etc.)"),
|
|
92
|
-
path: z.string().describe("API endpoint path")
|
|
93
|
-
})).describe("Array of matching actions (max 5)"),
|
|
94
|
-
metadata: z.object({
|
|
95
|
-
platform: z.string().describe("Platform that was searched"),
|
|
96
|
-
query: z.string().describe("Search query used"),
|
|
97
|
-
count: z.number().describe("Number of results returned")
|
|
98
|
-
}).describe("Metadata about the search results")
|
|
99
|
-
};
|
|
100
84
|
/**
|
|
101
85
|
* Tool configuration objects for registerTool
|
|
102
86
|
*/
|
|
@@ -106,26 +90,19 @@ export const listOneIntegrationsToolConfig = {
|
|
|
106
90
|
inputSchema: listOneIntegrationsInputSchema,
|
|
107
91
|
outputSchema: listOneIntegrationsOutputSchema
|
|
108
92
|
};
|
|
109
|
-
export const
|
|
110
|
-
title: "
|
|
111
|
-
description: "
|
|
112
|
-
inputSchema:
|
|
113
|
-
outputSchema: searchOnePlatformActionsOutputSchema
|
|
114
|
-
};
|
|
115
|
-
export const getOneActionKnowledgeToolConfig = {
|
|
116
|
-
title: "Get Action Knowledge",
|
|
117
|
-
description: "Get comprehensive documentation for a specific action including parameters, requirements, and usage examples. MANDATORY: You MUST call this tool before execute_one_action to understand the action's requirements, parameter structure, caveats, and proper usage. This loads the action documentation into context and is required for successful execution. Large documents come back as a digest (the sections needed to build a correct request, plus a list of what was omitted); request an omitted section by name with `section`, or the whole document with `full: true`.",
|
|
118
|
-
inputSchema: getOneActionKnowledgeInputSchema
|
|
93
|
+
export const findOneActionsToolConfig = {
|
|
94
|
+
title: "Find Platform Actions",
|
|
95
|
+
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
|
|
119
97
|
};
|
|
120
98
|
export const executeOneActionToolConfig = {
|
|
121
99
|
title: "Execute One Action",
|
|
122
|
-
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
|
|
100
|
+
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.",
|
|
123
101
|
inputSchema: executeOneActionInputSchema
|
|
124
102
|
};
|
|
125
103
|
/**
|
|
126
104
|
* Zod object schemas for type inference (for internal use)
|
|
127
105
|
*/
|
|
128
106
|
export const listOneIntegrationsZodSchema = z.object(listOneIntegrationsInputSchema);
|
|
129
|
-
export const
|
|
130
|
-
export const getOneActionKnowledgeZodSchema = z.object(getOneActionKnowledgeInputSchema);
|
|
107
|
+
export const findOneActionsZodSchema = z.object(findOneActionsInputSchema);
|
|
131
108
|
export const executeOneActionZodSchema = z.object(executeOneActionInputSchema);
|
package/package.json
CHANGED
|
@@ -1,12 +0,0 @@
|
|
|
1
|
-
import { describe, it } from 'node:test';
|
|
2
|
-
import assert from 'node:assert/strict';
|
|
3
|
-
import { isHiddenFromAgents } from './helpers.js';
|
|
4
|
-
describe('isHiddenFromAgents', () => {
|
|
5
|
-
it('flags only actions tagged hidden:agents', () => {
|
|
6
|
-
assert.equal(isHiddenFromAgents(['hidden:agents']), true);
|
|
7
|
-
assert.equal(isHiddenFromAgents(['custom', 'hidden:agents']), true);
|
|
8
|
-
assert.equal(isHiddenFromAgents(['custom']), false);
|
|
9
|
-
assert.equal(isHiddenFromAgents([]), false);
|
|
10
|
-
assert.equal(isHiddenFromAgents(undefined), false);
|
|
11
|
-
});
|
|
12
|
-
});
|