@withone/mcp 1.3.1 → 1.4.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/build/client.js CHANGED
@@ -74,6 +74,7 @@ export class OneClient {
74
74
  identity;
75
75
  identityType;
76
76
  connectionKeys;
77
+ fetchConnectors = true;
77
78
  connections = [];
78
79
  connectors = [];
79
80
  isInitialized = false;
@@ -102,6 +103,7 @@ export class OneClient {
102
103
  this.identity = optionsOrSecret.identity;
103
104
  this.identityType = optionsOrSecret.identityType;
104
105
  this.connectionKeys = optionsOrSecret.connectionKeys;
106
+ this.fetchConnectors = optionsOrSecret.fetchConnectors ?? true;
105
107
  }
106
108
  }
107
109
  /**
@@ -114,7 +116,7 @@ export class OneClient {
114
116
  }
115
117
  const results = await Promise.allSettled([
116
118
  this.fetchConnections(),
117
- this.fetchConnectionDefinitions(),
119
+ this.fetchConnectors ? this.fetchConnectionDefinitions() : Promise.resolve(),
118
120
  ]);
119
121
  const [connectionsResult, connectorsResult] = results;
120
122
  if (connectionsResult.status === 'rejected') {
@@ -358,9 +360,9 @@ export class OneClient {
358
360
  'x-one-connection-key': connectionKey,
359
361
  'x-one-action-id': action._id,
360
362
  };
361
- const finalActionPath = pathVariables
362
- ? replacePathVariables(action.path, pathVariables)
363
- : action.path;
363
+ // Always substitute: a template sent with its {{placeholders}} unfilled
364
+ // only fails upstream, while this names the variables the path expects.
365
+ const finalActionPath = replacePathVariables(action.path, pathVariables ?? {});
364
366
  const normalizedPath = finalActionPath.startsWith('/') ? finalActionPath : `/${finalActionPath}`;
365
367
  const url = `${this.baseUrl}/v1/passthrough${normalizedPath}`;
366
368
  let requestData = data;
@@ -473,7 +475,7 @@ export class OneClient {
473
475
  async refresh() {
474
476
  await Promise.allSettled([
475
477
  this.fetchConnections(),
476
- this.fetchConnectionDefinitions(),
478
+ this.fetchConnectors ? this.fetchConnectionDefinitions() : Promise.resolve(),
477
479
  ]);
478
480
  }
479
481
  }
@@ -55,6 +55,14 @@ describe("executePassthroughRequest keeps One's headers authoritative", () => {
55
55
  assert.equal(h['x-custom-trace'], 't-1', 'a non-reserved custom header still passes through');
56
56
  assert.equal(h['content-type'], 'application/json', 'the default content type is kept');
57
57
  });
58
+ it('names the expected path variables instead of sending an unfilled template', async () => {
59
+ const client = new OneClient(SECRET, base);
60
+ const templated = { ...ACTION, path: '/v1/things/{{thingId}}' };
61
+ await assert.rejects(client.executePassthroughRequest(exec({}), templated), /pass pathVariables with: thingId/);
62
+ assert.equal(received.length, 0, 'nothing is sent upstream');
63
+ await client.executePassthroughRequest(exec({ pathVariables: { thing_id: 7 } }), templated);
64
+ assert.equal(received.length, 1);
65
+ });
58
66
  it('a caller may still set Content-Type, in any casing', async () => {
59
67
  const client = new OneClient(SECRET, base);
60
68
  await client.executePassthroughRequest(exec({ headers: { 'content-type': 'text/plain' } }), ACTION);
package/build/helpers.js CHANGED
@@ -69,8 +69,7 @@ export async function fetchPaginatedData(baseUrl, headers, additionalParams) {
69
69
  * in full, plus a trailer and a `sections` list naming what was omitted); a
70
70
  * small doc is returned whole. `section` selects named section(s), `toc`
71
71
  * returns the full table of contents, and `full` (or `section: "all"`) returns
72
- * the document verbatim. Only the digest and full cases carry the API request
73
- * guidance, so `wrap` is set for those.
72
+ * the document verbatim.
74
73
  *
75
74
  * @param knowledge - The raw knowledge markdown for the action
76
75
  * @param method - The action's HTTP method (echoed into the envelope)
@@ -104,7 +103,6 @@ export function buildKnowledgeResponse(knowledge, method, platform, actionId, op
104
103
  ? `Section "${picked.query}" matches several sections; pick one by id or full heading.`
105
104
  : `No section named "${picked.query}". Use one of the names below, or full: true.`;
106
105
  return {
107
- wrap: false,
108
106
  text: `${lead}\n\n${listing}`,
109
107
  structured: {
110
108
  error: lead,
@@ -116,7 +114,6 @@ export function buildKnowledgeResponse(knowledge, method, platform, actionId, op
116
114
  };
117
115
  }
118
116
  return {
119
- wrap: false,
120
117
  text: picked.markdown,
121
118
  structured: {
122
119
  title: doc.title,
@@ -133,7 +130,6 @@ export function buildKnowledgeResponse(knowledge, method, platform, actionId, op
133
130
  ? fullToc.map((t) => `${' '.repeat(Math.max(0, t.level - 1))}${t.heading} [${t.id}] (${t.chars} chars)`).join('\n')
134
131
  : '(this document has no sections)';
135
132
  return {
136
- wrap: false,
137
133
  text: `Table of contents. Load any by name with section: "<id or heading>", or the whole document with full: true.\n\n${listing}`,
138
134
  structured: { title: doc.title, method, chars: doc.chars, sections: fullToc },
139
135
  };
@@ -141,18 +137,17 @@ export function buildKnowledgeResponse(knowledge, method, platform, actionId, op
141
137
  // `full`, and any document small enough to keep whole, are returned verbatim
142
138
  // rather than reconstructed from the parsed tree, so nothing is reformatted.
143
139
  if (wantFull) {
144
- return { wrap: true, text: knowledge, structured: { title: doc.title, method, truncated: false } };
140
+ return { text: knowledge, structured: { title: doc.title, method, truncated: false } };
145
141
  }
146
142
  const digest = buildDigest(doc);
147
143
  if (!digest.truncated) {
148
- return { wrap: true, text: knowledge, structured: { title: doc.title, method, truncated: false } };
144
+ return { text: knowledge, structured: { title: doc.title, method, truncated: false } };
149
145
  }
150
146
  const toc = omittedSections(digest);
151
147
  const text = [renderDigestBanner(digest), digest.markdown, renderDigestNotice(digest, platform, actionId)]
152
148
  .filter(Boolean)
153
149
  .join('\n\n');
154
150
  return {
155
- wrap: true,
156
151
  text,
157
152
  structured: {
158
153
  title: doc.title,
@@ -165,42 +160,6 @@ export function buildKnowledgeResponse(knowledge, method, platform, actionId, op
165
160
  },
166
161
  };
167
162
  }
168
- /**
169
- * Builds action knowledge with API request structure guidance.
170
- * @param knowledge - The raw knowledge content for the action
171
- * @param method - The HTTP method for the action
172
- * @param baseUrl - The base URL for One API
173
- * @param platform - The platform name (used for connection key)
174
- * @param actionId - The action ID
175
- * @returns Complete formatted knowledge with API guidance
176
- */
177
- export function buildActionKnowledgeWithGuidance(knowledge, method, baseUrl, platform, actionId) {
178
- const cleanBaseUrl = baseUrl.replace(/\/$/, '');
179
- return `${knowledge}
180
-
181
- API REQUEST STRUCTURE
182
- ======================
183
- URL: ${cleanBaseUrl}/v1/passthrough/{{PATH}}
184
-
185
- IMPORTANT: When constructing the URL, only include the API endpoint path after the base URL.
186
- Do NOT include the full third-party API URL.
187
-
188
- Examples:
189
- ✅ Correct: ${cleanBaseUrl}/v1/passthrough/crm/v3/objects/contacts/search
190
- ❌ Incorrect: ${cleanBaseUrl}/v1/passthrough/https://api.hubapi.com/crm/v3/objects/contacts/search
191
-
192
- METHOD: ${method}
193
-
194
- HEADERS:
195
- - x-one-secret: {{process.env.ONE_SECRET}}
196
- - x-one-connection-key: {{process.env.ONE_${platform.toUpperCase()}_CONNECTION_KEY}}
197
- - x-one-action-id: ${actionId}
198
- - ... (other headers)
199
-
200
- BODY: {{BODY}}
201
-
202
- QUERY PARAMS: {{QUERY_PARAMS}}`;
203
- }
204
163
  /**
205
164
  * Uppercases a platform id into an env-var segment, collapsing every
206
165
  * non-alphanumeric run to a single underscore ("ship-station" -> "SHIP_STATION").
@@ -301,37 +260,38 @@ const response = await fetch("${apiBase}/v1/passthrough${path}", {
301
260
  });
302
261
  \`\`\``;
303
262
  }
263
+ /** Folds a path variable name for loose matching: `ticket_id`, `ticketId`, `TICKET-ID` all fold to `ticketid`. */
264
+ const foldVariableName = (name) => name.toLowerCase().replace(/[\s_-]/g, '');
304
265
  /**
305
266
  * Replaces path variables in a template string with actual values.
306
- * Path variables can be in either format: {variableName} or {{variableName}} and will be replaced with corresponding values.
267
+ * Path variables can be in either format: {variableName} or {{variableName}}.
268
+ * A variable is looked up by its exact name first, then by name ignoring case,
269
+ * underscores, hyphens and spaces, because knowledge docs and path templates
270
+ * do not always spell a variable the same way (`ticket_id` vs `{{ticketId}}`).
307
271
  * @param path - The template string containing path variables in {variableName} or {{variableName}} format
308
272
  * @param variables - Object containing variable names as keys and their replacement values
309
273
  * @returns The path string with all variables replaced by their encoded values
310
- * @throws Error if any required variable is missing, null, undefined, or empty string
274
+ * @throws Error if any required variable is missing, null, undefined, or empty string, naming every variable the path expects
311
275
  */
312
276
  export function replacePathVariables(path, variables) {
313
277
  if (!path)
314
278
  return path;
315
- let result = path;
316
- // First, replace double bracket variables {{variableName}}
317
- result = result.replace(/\{\{([^}]+)\}\}/g, (match, variable) => {
318
- const trimmedVariable = variable.trim();
319
- const value = variables[trimmedVariable];
320
- if (value === undefined || value === null || value === '') {
321
- throw new Error(`Missing value for path variable: ${trimmedVariable}`);
322
- }
323
- return encodeURIComponent(value.toString());
324
- });
325
- // Then, replace single bracket variables {variableName}
326
- result = result.replace(/\{([^}]+)\}/g, (match, variable) => {
327
- const trimmedVariable = variable.trim();
328
- const value = variables[trimmedVariable];
279
+ const expected = [...path.matchAll(/\{\{([^}]+)\}\}|\{([^{}]+)\}/g)].map((m) => (m[1] ?? m[2]).trim());
280
+ const folded = new Map();
281
+ for (const [key, value] of Object.entries(variables ?? {})) {
282
+ if (!folded.has(foldVariableName(key)))
283
+ folded.set(foldVariableName(key), value);
284
+ }
285
+ const lookup = (variable) => {
286
+ const name = variable.trim();
287
+ const value = name in (variables ?? {}) ? variables[name] : folded.get(foldVariableName(name));
329
288
  if (value === undefined || value === null || value === '') {
330
- throw new Error(`Missing value for path variable: ${trimmedVariable}`);
289
+ throw new Error(`Missing value for path variable: ${name}. This action's path is ${path}; pass pathVariables with: ${[...new Set(expected)].join(', ')}.`);
331
290
  }
332
291
  return encodeURIComponent(value.toString());
333
- });
334
- return result;
292
+ };
293
+ // Double brackets first, so {{name}} is not read as {name} wrapped in braces.
294
+ return path.replace(/\{\{([^}]+)\}\}/g, (_match, variable) => lookup(variable)).replace(/\{([^}]+)\}/g, (_match, variable) => lookup(variable));
335
295
  }
336
296
  const PERMISSION_METHODS = {
337
297
  read: ["GET"],
@@ -375,3 +335,45 @@ export function computeConnectionAccess(platform, permissions, allowedActionIds,
375
335
  }
376
336
  return { policy: "full" };
377
337
  }
338
+ /**
339
+ * Builds the `list_one_integrations` response: the user's active connections,
340
+ * each with the access the current config confers. Access is only defined per
341
+ * connection, so unconnected platforms are left out unless `available` is
342
+ * passed (knowledge/code-gen mode, where an agent may write code against a
343
+ * platform the user has not connected yet and needs its kebab-case name).
344
+ * @param connections - The user's connections
345
+ * @param access - The access config: permission level, action allowlist, and
346
+ * the allowlist already resolved to action metadata
347
+ * @param available - Connection definitions to list as available platforms
348
+ */
349
+ export function buildIntegrationsResponse(connections, access, available) {
350
+ const active = connections.filter((conn) => conn.active);
351
+ const response = {
352
+ connections: active.map((conn) => ({
353
+ platform: conn.platform,
354
+ key: conn.key,
355
+ tags: conn.tags ?? [],
356
+ access: computeConnectionAccess(conn.platform, access.permissions, access.actionIds, access.resolvedAllowed),
357
+ })),
358
+ summary: { connectedCount: active.length },
359
+ };
360
+ if (available) {
361
+ const platforms = available.filter((def) => def.active && !def.deprecated);
362
+ response.availablePlatforms = platforms.map((def) => ({
363
+ platform: def.platform,
364
+ name: def.name,
365
+ category: def.category,
366
+ }));
367
+ response.summary.availableCount = platforms.length;
368
+ }
369
+ return response;
370
+ }
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
+ }
@@ -0,0 +1,12 @@
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
+ });
package/build/index.js CHANGED
@@ -11,7 +11,7 @@ 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 { buildActionKnowledgeWithGuidance, buildKnowledgeModeGuidance, buildKnowledgeResponse, filterByPermissions, isMethodAllowed, isActionAllowed, computeConnectionAccess, } from './helpers.js';
14
+ import { buildKnowledgeModeGuidance, buildKnowledgeResponse, filterByPermissions, isMethodAllowed, isActionAllowed, buildIntegrationsResponse, isHiddenFromAgents, } from './helpers.js';
15
15
  import { listOneIntegrationsToolConfig, searchOnePlatformActionsToolConfig, getOneActionKnowledgeToolConfig, executeOneActionToolConfig } from './schemas.js';
16
16
  const SERVER_NAME = "one-mcp-server";
17
17
  const SERVER_VERSION = "1.0.0";
@@ -41,6 +41,8 @@ const oneClient = new OneClient({
41
41
  identity: ONE_IDENTITY,
42
42
  identityType: ONE_IDENTITY_TYPE,
43
43
  connectionKeys: ONE_CONNECTION_KEYS,
44
+ // The platform catalog is only listed in knowledge/code-gen mode.
45
+ fetchConnectors: ONE_KNOWLEDGE_AGENT,
44
46
  });
45
47
  let oneInitialized = false;
46
48
  let initializationPromise = null;
@@ -88,37 +90,18 @@ if (!ONE_KNOWLEDGE_AGENT) {
88
90
  }
89
91
  async function handleGetIntegrations(args) {
90
92
  try {
91
- const connectedIntegrations = oneClient.getUserConnections();
92
- const availableIntegrations = oneClient.getAvailableConnectors();
93
- const activeConnections = connectedIntegrations.filter(conn => conn.active);
94
- let activePlatforms = availableIntegrations.filter(def => def.active && !def.deprecated);
95
- // When connection keys are scoped, only show platforms that match active connections
96
- if (!ONE_CONNECTION_KEYS.includes("*")) {
97
- const connectedPlatforms = new Set(activeConnections.map(conn => conn.platform));
98
- activePlatforms = activePlatforms.filter(def => connectedPlatforms.has(def.platform));
99
- }
100
93
  // Resolve the action allowlist to per-platform metadata (method-filtered by
101
94
  // the permission level) once, so each connection can report the exact
102
95
  // actions it may run. Only needed when an action allowlist is set.
103
96
  const resolvedAllowed = (await oneClient.resolveAllowedActions(ONE_ACTION_IDS))
104
97
  .filter(action => isMethodAllowed(action.method, ONE_PERMISSIONS));
105
- const structuredResponse = {
106
- connections: activeConnections.map(conn => ({
107
- platform: conn.platform,
108
- key: conn.key,
109
- tags: conn.tags ?? [],
110
- access: computeConnectionAccess(conn.platform, ONE_PERMISSIONS, ONE_ACTION_IDS, resolvedAllowed)
111
- })),
112
- availablePlatforms: activePlatforms.map(def => ({
113
- platform: def.platform,
114
- name: def.name,
115
- category: def.category
116
- })),
117
- summary: {
118
- connectedCount: activeConnections.length,
119
- availableCount: activePlatforms.length
120
- }
121
- };
98
+ // Execute mode lists only connections: every connection carries its
99
+ // access, and the full catalog (800+ platforms) would sit in the agent's
100
+ // context for the rest of the session. Knowledge/code-gen mode also lists
101
+ // available platforms so code can target one that is not connected yet,
102
+ // unless connection keys are scoped.
103
+ const includeAvailable = ONE_KNOWLEDGE_AGENT && ONE_CONNECTION_KEYS.includes("*");
104
+ const structuredResponse = buildIntegrationsResponse(oneClient.getUserConnections(), { permissions: ONE_PERMISSIONS, actionIds: ONE_ACTION_IDS, resolvedAllowed }, includeAvailable ? oneClient.getAvailableConnectors() : undefined);
122
105
  return {
123
106
  content: [
124
107
  {
@@ -136,7 +119,9 @@ async function handleGetIntegrations(args) {
136
119
  async function handleSearchPlatformActions(args) {
137
120
  try {
138
121
  // Force knowledge mode when ONE_KNOWLEDGE_AGENT is enabled
139
- const agentType = ONE_KNOWLEDGE_AGENT ? "knowledge" : args.agentType;
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");
140
125
  let actions = await oneClient.searchAvailableActions(args.platform, args.query, agentType);
141
126
  // Apply permission-level filtering
142
127
  actions = filterByPermissions(actions, ONE_PERMISSIONS);
@@ -228,20 +213,23 @@ async function handleGetActionKnowledge(args) {
228
213
  ],
229
214
  };
230
215
  }
216
+ if (isHiddenFromAgents((await oneClient.getActionDetails(actionId)).tags)) {
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
+ }
231
219
  const { knowledge, method } = await oneClient.getActionKnowledge(actionId);
232
220
  const response = buildKnowledgeResponse(knowledge, method, args.platform, actionId, {
233
221
  section: args.section,
234
222
  full: args.full,
235
223
  toc: args.toc,
236
224
  });
237
- const text = response.wrap
238
- ? buildActionKnowledgeWithGuidance(response.text, method, oneClient.getBaseUrl(), args.platform, actionId)
239
- : response.text;
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.
240
228
  return {
241
229
  content: [
242
230
  {
243
231
  type: "text",
244
- text,
232
+ text: response.text,
245
233
  },
246
234
  ],
247
235
  structuredContent: response.structured,
@@ -263,6 +251,9 @@ async function handleExecuteOneAction(args) {
263
251
  }
264
252
  }
265
253
  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
+ }
266
257
  if (!isMethodAllowed(actionDetails.method, ONE_PERMISSIONS)) {
267
258
  throw new McpError(ErrorCode.InvalidRequest, `Method "${actionDetails.method}" is not allowed under "${ONE_PERMISSIONS}" permission level`);
268
259
  }
@@ -0,0 +1,40 @@
1
+ import { describe, it } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { buildIntegrationsResponse } from './helpers.js';
4
+ const conn = (platform, active = true) => ({ key: `live::${platform}::default::k`, platform, tags: [], active });
5
+ const def = (platform, extra = {}) => ({ platform, name: platform.toUpperCase(), category: 'Test', active: true, deprecated: false, ...extra });
6
+ const FULL = { permissions: 'admin', actionIds: ['*'], resolvedAllowed: [] };
7
+ describe('buildIntegrationsResponse', () => {
8
+ it('lists only active connections, without the platform catalog', () => {
9
+ const r = buildIntegrationsResponse([conn('gmail'), conn('slack', false)], FULL);
10
+ assert.deepEqual(r.connections.map((c) => c.platform), ['gmail']);
11
+ assert.equal(r.availablePlatforms, undefined);
12
+ assert.deepEqual(r.summary, { connectedCount: 1 });
13
+ });
14
+ it('keeps the per-connection access', () => {
15
+ const r = buildIntegrationsResponse([conn('gmail')], { permissions: 'read', actionIds: ['*'], resolvedAllowed: [] });
16
+ assert.deepEqual(r.connections[0].access, { policy: 'methods', methods: ['GET'] });
17
+ const scoped = buildIntegrationsResponse([conn('gmail')], {
18
+ permissions: 'admin',
19
+ actionIds: ['act_1'],
20
+ resolvedAllowed: [{ actionId: 'act_1', title: 'Send Email', method: 'POST', platform: 'gmail' }],
21
+ });
22
+ assert.deepEqual(scoped.connections[0].access, {
23
+ policy: 'actions',
24
+ actions: [{ actionId: 'act_1', title: 'Send Email', method: 'POST' }],
25
+ });
26
+ });
27
+ it('adds active, non-deprecated platforms when a catalog is passed (code-gen mode)', () => {
28
+ const r = buildIntegrationsResponse([conn('gmail')], FULL, [
29
+ def('gmail'),
30
+ def('hubspot'),
31
+ def('old-crm', { deprecated: true }),
32
+ def('beta-thing', { active: false }),
33
+ ]);
34
+ assert.deepEqual(r.availablePlatforms, [
35
+ { platform: 'gmail', name: 'GMAIL', category: 'Test' },
36
+ { platform: 'hubspot', name: 'HUBSPOT', category: 'Test' },
37
+ ]);
38
+ assert.deepEqual(r.summary, { connectedCount: 1, availableCount: 2 });
39
+ });
40
+ });
@@ -18,7 +18,6 @@ const SMALL = '# Tiny\n\n## Method\nGET\n\n## URL\n/x';
18
18
  describe('buildKnowledgeResponse', () => {
19
19
  it('digests a large doc: request-building sections in full, omitted ones named in the envelope', () => {
20
20
  const r = buildKnowledgeResponse(LARGE, 'POST', 'gmail', 'act_1', {});
21
- assert.equal(r.wrap, true);
22
21
  assert.equal(r.structured.truncated, true);
23
22
  assert.ok(r.structured.omitted >= 1);
24
23
  assert.ok(Array.isArray(r.structured.sections) && r.structured.sections.length >= 1);
@@ -31,20 +30,17 @@ describe('buildKnowledgeResponse', () => {
31
30
  });
32
31
  it('returns the whole table of contents for toc, with no bodies', () => {
33
32
  const r = buildKnowledgeResponse(LARGE, 'POST', 'gmail', 'act_1', { toc: true });
34
- assert.equal(r.wrap, false);
35
33
  assert.ok(r.structured.sections.length >= 5);
36
34
  assert.ok(r.text.includes('[response-fields]'));
37
35
  assert.ok(!r.text.includes('Response Fields field 100'));
38
36
  });
39
37
  it('returns a named section with its body and what it resolved to', () => {
40
38
  const r = buildKnowledgeResponse(LARGE, 'POST', 'gmail', 'act_1', { section: 'Response Fields' });
41
- assert.equal(r.wrap, false);
42
39
  assert.deepEqual(r.structured.resolved.map((s) => s.heading), ['Response Fields']);
43
40
  assert.ok(r.text.includes('Response Fields field 100'));
44
41
  });
45
42
  it('reports a not-found section with a bounded candidate list', () => {
46
43
  const r = buildKnowledgeResponse(LARGE, 'POST', 'gmail', 'act_1', { section: 'nope' });
47
- assert.equal(r.wrap, false);
48
44
  assert.ok(r.structured.error);
49
45
  assert.equal(r.structured.reason, 'not-found');
50
46
  assert.ok(r.structured.sections.length >= 1);
@@ -53,21 +49,26 @@ describe('buildKnowledgeResponse', () => {
53
49
  it('treats section: "all" and full: true as the verbatim whole document', () => {
54
50
  for (const opts of [{ section: 'all' }, { full: true }]) {
55
51
  const r = buildKnowledgeResponse(LARGE, 'POST', 'gmail', 'act_1', opts);
56
- assert.equal(r.wrap, true);
57
52
  assert.equal(r.text, LARGE);
58
53
  assert.equal(r.structured.truncated, false);
59
54
  }
60
55
  });
61
56
  it('returns a small doc verbatim with no digest', () => {
62
57
  const r = buildKnowledgeResponse(SMALL, 'GET', 'gmail', 'act_1', {});
63
- assert.equal(r.wrap, true);
64
58
  assert.equal(r.text, SMALL);
65
59
  assert.equal(r.structured.truncated, false);
66
60
  });
67
61
  it('section wins over full when both are set', () => {
68
62
  const r = buildKnowledgeResponse(LARGE, 'POST', 'gmail', 'act_1', { section: 'Response Fields', full: true });
69
- assert.equal(r.wrap, false);
70
63
  assert.notEqual(r.text, LARGE);
71
64
  assert.ok(r.text.includes('Response Fields field 100'));
72
65
  });
73
66
  });
67
+ describe('execute-mode knowledge text', () => {
68
+ it('carries no raw-HTTP request guidance', () => {
69
+ for (const options of [{}, { full: true }, { section: 'Method' }, { toc: true }]) {
70
+ const r = buildKnowledgeResponse(LARGE, 'POST', 'gmail', 'act_1', options);
71
+ assert.doesNotMatch(r.text, /API REQUEST STRUCTURE|x-one-secret|\/v1\/passthrough/);
72
+ }
73
+ });
74
+ });
@@ -0,0 +1,26 @@
1
+ import { describe, it } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { replacePathVariables } from './helpers.js';
4
+ describe('replacePathVariables', () => {
5
+ it('fills exact names in both bracket styles', () => {
6
+ assert.equal(replacePathVariables('/tickets/{{ticketId}}/comments/{id}', { ticketId: 42, id: 'c 1' }), '/tickets/42/comments/c%201');
7
+ });
8
+ it('matches a variable regardless of case, underscores and hyphens', () => {
9
+ for (const key of ['ticket_id', 'TICKET_ID', 'ticket-id', 'TicketId']) {
10
+ assert.equal(replacePathVariables('/api/v2/tickets/{{ticketId}}', { [key]: 48213 }), '/api/v2/tickets/48213', key);
11
+ }
12
+ assert.equal(replacePathVariables('/repos/{owner}/{repo}/pulls/{{pullNumber}}', { owner: 'acme', repo: 'web', pull_number: 517 }), '/repos/acme/web/pulls/517');
13
+ });
14
+ it('prefers the exact name over a loose match', () => {
15
+ assert.equal(replacePathVariables('/x/{{ticketId}}', { ticket_id: 'loose', ticketId: 'exact' }), '/x/exact');
16
+ });
17
+ it('names every expected variable when one is missing', () => {
18
+ assert.throws(() => replacePathVariables('/repos/{{owner}}/{{repo}}/pulls/{{pullNumber}}', { owner: 'acme', repo: 'web' }), /Missing value for path variable: pullNumber\..*pass pathVariables with: owner, repo, pullNumber\./);
19
+ });
20
+ it('leaves a path without variables untouched, even with no variables given', () => {
21
+ assert.equal(replacePathVariables('/v1/gmail/send-email', {}), '/v1/gmail/send-email');
22
+ });
23
+ it('rejects empty values', () => {
24
+ assert.throws(() => replacePathVariables('/x/{{id}}', { id: '' }), /Missing value for path variable: id/);
25
+ });
26
+ });
package/build/schemas.js CHANGED
@@ -16,16 +16,16 @@ export const listOneIntegrationsInputSchema = {};
16
16
  * Schema for searching platform actions
17
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 version of the platform name that comes from the list_one_integrations tool AVAILABLE PLATFORMS section."),
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
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 'knowledge' if not specified.")
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
22
  };
23
23
  /**
24
24
  * Schema for getting action knowledge
25
25
  */
26
26
  export const getOneActionKnowledgeInputSchema = {
27
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 version of the platform name that comes from the list_one_integrations tool AVAILABLE PLATFORMS section."),
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
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
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
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.")
@@ -75,11 +75,11 @@ export const listOneIntegrationsOutputSchema = {
75
75
  platform: z.string(),
76
76
  name: z.string(),
77
77
  category: z.string()
78
- })).describe("Array of available platforms that can be connected"),
78
+ })).optional().describe("Platforms that can be connected. Only listed in knowledge/code-gen mode"),
79
79
  summary: z.object({
80
80
  connectedCount: z.number(),
81
- availableCount: z.number()
82
- }).describe("Summary statistics of connections and available platforms")
81
+ availableCount: z.number().optional()
82
+ }).describe("Counts of connections (and available platforms in knowledge/code-gen mode)")
83
83
  };
84
84
  /**
85
85
  * Output schemas for search_one_platform_actions tool
@@ -102,7 +102,7 @@ export const searchOnePlatformActionsOutputSchema = {
102
102
  */
103
103
  export const listOneIntegrationsToolConfig = {
104
104
  title: "List One Integrations",
105
- description: "List all available One integrations and platforms. ALWAYS call this tool first in any workflow to discover what platforms and connections are available. This returns the connections that the user has and all available One platforms in kebab-case format (e.g., 'ship-station', 'shopify') which you'll need for subsequent tool calls. Each connection carries an `access` field describing what you may run on it: `full`, a set of allowed HTTP `methods`, or a specific list of `actions` (each with `actionId`, `title`, `method`). When a connection is action-scoped, its `actions` are exactly what may run, so you need not search.",
105
+ description: "List the platforms the user has connected on One. ALWAYS call this tool first in any workflow. Each connection has the kebab-case `platform` name (e.g., 'ship-station', 'shopify') and the `key` (the connectionKey for execute_one_action) you'll need for subsequent tool calls. Each connection carries an `access` field describing what you may run on it: `full`, a set of allowed HTTP `methods`, or a specific list of `actions` (each with `actionId`, `title`, `method`). When a connection is action-scoped, its `actions` are exactly what may run, so you need not search.",
106
106
  inputSchema: listOneIntegrationsInputSchema,
107
107
  outputSchema: listOneIntegrationsOutputSchema
108
108
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@withone/mcp",
3
- "version": "1.3.1",
3
+ "version": "1.4.0",
4
4
  "description": "A Model Context Protocol Server for One",
5
5
  "type": "module",
6
6
  "bin": {