@withone/mcp 1.3.1 → 1.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/build/client.js +7 -5
- package/build/client.test.js +8 -0
- package/build/helpers.js +56 -63
- package/build/index.js +17 -32
- package/build/integrations-response.test.js +40 -0
- package/build/knowledge-response.test.js +8 -7
- package/build/path-variables.test.js +26 -0
- package/build/schemas.js +7 -7
- package/package.json +1 -1
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
|
-
|
|
362
|
-
|
|
363
|
-
|
|
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
|
}
|
package/build/client.test.js
CHANGED
|
@@ -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.
|
|
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 {
|
|
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 {
|
|
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}}
|
|
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
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
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: ${
|
|
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
|
-
|
|
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,36 @@ 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
|
+
}
|
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 {
|
|
14
|
+
import { buildKnowledgeModeGuidance, buildKnowledgeResponse, filterByPermissions, isMethodAllowed, isActionAllowed, buildIntegrationsResponse, } 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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
|
|
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);
|
|
@@ -234,14 +219,14 @@ async function handleGetActionKnowledge(args) {
|
|
|
234
219
|
full: args.full,
|
|
235
220
|
toc: args.toc,
|
|
236
221
|
});
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
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.
|
|
240
225
|
return {
|
|
241
226
|
content: [
|
|
242
227
|
{
|
|
243
228
|
type: "text",
|
|
244
|
-
text,
|
|
229
|
+
text: response.text,
|
|
245
230
|
},
|
|
246
231
|
],
|
|
247
232
|
structuredContent: response.structured,
|
|
@@ -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
|
|
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 '
|
|
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
|
|
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("
|
|
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("
|
|
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
|
|
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
|
};
|