@withone/mcp 1.4.1 → 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 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. Search for actions, read documentation, and execute API calls across platforms, without having to manage OAuth tokens or API keys.
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 4 tools exposed regardless of how many platforms or actions you connect. Actions are search-based, so your agent's context window stays clean.
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 four MCP tools:
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
- | `search_one_platform_actions` | Search for actions on a specific platform |
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 four tools are available.
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. `get_one_action_knowledge` responses additionally include an Integration Code Guide (passthrough URL, `x-one-*` headers, `ONE_SECRET` / `ONE_{PLATFORM}_CONNECTION_KEY` env vars, backend-only placement) for writing application code |
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
- * Searches for actions on a specific platform using a query
188
- * @param platform - The platform name to search actions for
189
- * @param query - The search query to find relevant actions
190
- * @param agentType - The type of agent context (execute or knowledge)
191
- * @returns Array of top 5 most relevant actions for the platform
192
- * @throws {Error} If platform or query is not provided or API request fails
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 searchAvailableActions(platform, query, agentType) {
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 headers = this.generateHeaders();
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 searching available actions:", describeError(error));
202
+ console.error("Error finding actions:", describeError(error));
224
203
  if (axios.isAxiosError(error)) {
225
- throw new Error(`Failed to search available actions: ${error.response?.status} ${error.response?.statusText}`);
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 search available actions");
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 digest = buildDigest(doc);
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)
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 { buildKnowledgeModeGuidance, buildKnowledgeResponse, filterByPermissions, isMethodAllowed, isActionAllowed, buildIntegrationsResponse, } from './helpers.js';
15
- import { listOneIntegrationsToolConfig, searchOnePlatformActionsToolConfig, getOneActionKnowledgeToolConfig, executeOneActionToolConfig } from './schemas.js';
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("search_one_platform_actions", searchOnePlatformActionsToolConfig, async (args) => {
78
+ server.registerTool("find_one_actions", findOneActionsToolConfig, async (args) => {
78
79
  await initializeOne();
79
- return await handleSearchPlatformActions(args);
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,124 +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 handleSearchPlatformActions(args) {
116
+ async function handleFindOneActions(args) {
120
117
  try {
121
- // Force knowledge mode when ONE_KNOWLEDGE_AGENT is enabled
122
- // With execute available, default to the execute catalog: it hides actions
123
- // tagged hidden:agents and offers the custom actions that replace them.
124
- const agentType = ONE_KNOWLEDGE_AGENT ? "knowledge" : (args.agentType ?? "execute");
125
- let actions = await oneClient.searchAvailableActions(args.platform, args.query, agentType);
126
- // Apply permission-level filtering
127
- actions = filterByPermissions(actions, ONE_PERMISSIONS);
128
- // Apply action allowlist filtering
129
- actions = actions.filter(a => isActionAllowed(a.systemId, ONE_ACTION_IDS));
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: responseText,
131
+ text,
178
132
  },
179
133
  ],
180
- structuredContent: structuredResponse,
181
134
  };
182
135
  }
183
136
  catch (error) {
184
- throw new McpError(ErrorCode.InternalError, `Failed to search platform actions: ${error instanceof Error ? error.message : 'Unknown error'}`);
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
- const { knowledge, method } = await oneClient.getActionKnowledge(actionId);
217
- const response = buildKnowledgeResponse(knowledge, method, args.platform, actionId, {
218
- section: args.section,
219
- full: args.full,
220
- toc: args.toc,
221
- });
222
- // Execute mode returns the documentation alone: execute_one_action builds
223
- // the passthrough request itself, so the raw-HTTP guidance code-gen mode
224
- // appends (base URL, x-one-* headers, env var names) would only add tokens.
225
- return {
226
- content: [
227
- {
228
- type: "text",
229
- text: response.text,
230
- },
231
- ],
232
- structuredContent: response.structured,
233
- };
234
- }
235
- catch (error) {
236
- 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'}`);
237
146
  }
238
147
  }
239
148
  async function handleExecuteOneAction(args) {
@@ -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('get_one_action_knowledge'));
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 calling get_one_action_knowledge again with section: "${id}"]_`;
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
- `Load one or more by calling get_one_action_knowledge again for ${platform} / ${actionId} with section: "${topMost[0]?.heading ?? 'Response'}" (comma-separate several).`,
437
- `Load the whole document with full: true.`,
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 searching platform actions
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 searchOnePlatformActionsInputSchema = {
19
- platform: z.string().describe("The platform name to search actions for (e.g., 'ship-station', 'shopify'). This is the kebab-case platform name from list_one_integrations."),
20
- query: z.string().describe("The search query to find relevant actions (e.g., 'search contacts', 'create customer', 'send email'). Be specific about what you want to do."),
21
- agentType: z.enum(["execute", "knowledge"]).optional().describe("The type of agent context: 'execute' if the user wants to execute an action, 'knowledge' if they want to get information or write code. Defaults to 'execute' when this server can execute actions (knowledge-only servers always use 'knowledge').")
22
- };
23
- /**
24
- * Schema for getting action knowledge
25
- */
26
- export const getOneActionKnowledgeInputSchema = {
27
- actionId: z.string().describe("The action ID to get knowledge for (from the actions list returned by search_one_platform_actions). REQUIRED: This tool must be called before execute_one_action to load the action's documentation into context."),
28
- platform: z.string().describe("The platform name to get knowledge for (e.g., 'ship-station', 'shopify'). This is the kebab-case platform name from list_one_integrations."),
29
- section: z.string().optional().describe("Load specific section(s) of the documentation by name (e.g. 'Response Fields', or comma-separated 'Response Fields, Optional Request Body Fields'). Large docs return a digest first; use this on a follow-up call to pull a section the digest omitted."),
30
- full: z.boolean().optional().describe("Return the entire documentation verbatim instead of the token-saving digest. Ignored when `section` is set. Leave unset by default; only set it when you genuinely need every section."),
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("Action ID from search_one_platform_actions"),
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 searchOnePlatformActionsToolConfig = {
110
- title: "Search Platform Actions",
111
- description: "Search for relevant actions on a specific platform using a query. Call this after list_one_integrations to find actions that match your intent. Returns the top 5 most relevant actions based on your search query. Use the exact kebab-case platform name from the integrations list.",
112
- inputSchema: searchOnePlatformActionsInputSchema,
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 get_one_action_knowledge and provide implementation guidance instead. REQUIRED WORKFLOW: Must call get_one_action_knowledge first. If uncertain about execution intent or parameters, ask for confirmation before proceeding.",
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 searchOnePlatformActionsZodSchema = z.object(searchOnePlatformActionsInputSchema);
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,6 +1,6 @@
1
1
  {
2
2
  "name": "@withone/mcp",
3
- "version": "1.4.1",
3
+ "version": "2.0.0",
4
4
  "description": "A Model Context Protocol Server for One",
5
5
  "type": "module",
6
6
  "bin": {