@rcs-campaigns/agent-toolkit 0.1.5
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 +176 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +375 -0
- package/dist/client.d.ts +97 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +175 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +31 -0
- package/dist/mcp.d.ts +3 -0
- package/dist/mcp.d.ts.map +1 -0
- package/dist/mcp.js +178 -0
- package/dist/openapi.d.ts +36 -0
- package/dist/openapi.d.ts.map +1 -0
- package/dist/openapi.js +590 -0
- package/dist/tools.d.ts +93 -0
- package/dist/tools.d.ts.map +1 -0
- package/dist/tools.js +1333 -0
- package/package.json +69 -0
package/dist/openapi.js
ADDED
|
@@ -0,0 +1,590 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.toOpenApiPath = toOpenApiPath;
|
|
4
|
+
exports.pathParamsOf = pathParamsOf;
|
|
5
|
+
exports.buildOpenApiDocument = buildOpenApiDocument;
|
|
6
|
+
/**
|
|
7
|
+
* The agent gateway's OpenAPI 3.1 document, generated from `TOOLS`.
|
|
8
|
+
*
|
|
9
|
+
* Why generated, and why HERE: before MSG-1833 the public API reference was
|
|
10
|
+
* hand-copied into two website repos and the capabilities catalog was a
|
|
11
|
+
* hand-written literal in `apps/server` — four lists of the same surface, none
|
|
12
|
+
* of which checked the others. A route reached the docs only if somebody
|
|
13
|
+
* remembered to retype it twice. This module makes the catalog the source: the
|
|
14
|
+
* server serves `buildOpenApiDocument(...)` at `GET /api/agent/openapi.json`,
|
|
15
|
+
* so a tool that exists is documented and a tool that does not, is not.
|
|
16
|
+
*
|
|
17
|
+
* Pure and dependency-free by design (no `zod-to-openapi`, none is in the repo):
|
|
18
|
+
* `ToolDef.inputSchema` is already JSON Schema, and OpenAPI 3.1 embeds JSON
|
|
19
|
+
* Schema 2020-12 directly, so each tool's schema mounts near-verbatim.
|
|
20
|
+
*
|
|
21
|
+
* The one non-obvious rule is the parameter/body split:
|
|
22
|
+
* - a property whose name matches a `:param` in `path` becomes a PATH
|
|
23
|
+
* parameter (always required);
|
|
24
|
+
* - of the rest, GET/DELETE expand into QUERY parameters and
|
|
25
|
+
* POST/PUT/PATCH become the `requestBody`.
|
|
26
|
+
* Every current tool satisfies the invariant this rests on (every `:param` is
|
|
27
|
+
* also an `inputSchema` property); the toolkit's own tests pin it so a future
|
|
28
|
+
* tool cannot break the generator silently.
|
|
29
|
+
*/
|
|
30
|
+
const tools_1 = require("./tools");
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
// Small pure helpers
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
/** `/conversations/:id` → `/conversations/{id}` (OpenAPI path templating). */
|
|
35
|
+
function toOpenApiPath(path) {
|
|
36
|
+
return path.replace(/:([A-Za-z0-9_]+)/g, '{$1}');
|
|
37
|
+
}
|
|
38
|
+
/** The `:param` names in an Express-style path, in order. */
|
|
39
|
+
function pathParamsOf(path) {
|
|
40
|
+
return [...path.matchAll(/:([A-Za-z0-9_]+)/g)].map((m) => m[1]);
|
|
41
|
+
}
|
|
42
|
+
/** `create_webhook` → `CreateWebhook`. */
|
|
43
|
+
function pascal(name) {
|
|
44
|
+
return name
|
|
45
|
+
.split(/[_\-\s]+/)
|
|
46
|
+
.filter(Boolean)
|
|
47
|
+
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
|
48
|
+
.join('');
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* The first sentence of a tool description, for `summary`.
|
|
52
|
+
*
|
|
53
|
+
* Tool descriptions are written for a model and run long (they carry the
|
|
54
|
+
* gotchas an agent needs). A generated reference still wants a one-line label
|
|
55
|
+
* per operation, and the full text stays as `description`.
|
|
56
|
+
*/
|
|
57
|
+
function firstSentence(text) {
|
|
58
|
+
const trimmed = text.trim();
|
|
59
|
+
const stop = trimmed.search(/\.(\s|$)/);
|
|
60
|
+
const first = stop === -1 ? trimmed : trimmed.slice(0, stop + 1);
|
|
61
|
+
return first.length > 160 ? `${first.slice(0, 157)}…` : first;
|
|
62
|
+
}
|
|
63
|
+
/** Group a tool under a docs tag by the resource its path names. */
|
|
64
|
+
function tagFor(tool) {
|
|
65
|
+
const head = tool.path.split('/')[1] ?? '';
|
|
66
|
+
switch (head) {
|
|
67
|
+
case 'templates':
|
|
68
|
+
case 'marketplace':
|
|
69
|
+
return 'Templates';
|
|
70
|
+
case 'surveys':
|
|
71
|
+
return 'Surveys';
|
|
72
|
+
case 'messages':
|
|
73
|
+
return 'Messages';
|
|
74
|
+
case 'media':
|
|
75
|
+
return 'Media';
|
|
76
|
+
case 'audiences':
|
|
77
|
+
return 'Audiences';
|
|
78
|
+
case 'contacts':
|
|
79
|
+
return 'Contacts';
|
|
80
|
+
case 'conversations':
|
|
81
|
+
return 'Conversations';
|
|
82
|
+
case 'webhooks':
|
|
83
|
+
return 'Webhooks';
|
|
84
|
+
case 'usage':
|
|
85
|
+
return 'Usage';
|
|
86
|
+
default:
|
|
87
|
+
return 'Discovery';
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* The tools that reach a real phone and bill the org, and so sit behind the
|
|
92
|
+
* TIGHTER per-key limiter (`agentSendLimiter`) rather than the general one.
|
|
93
|
+
* Named here so the generated 429 description carries the number that actually
|
|
94
|
+
* applies to the operation rather than a single averaged claim.
|
|
95
|
+
*/
|
|
96
|
+
const TIGHTLY_LIMITED = new Set([
|
|
97
|
+
'send_message',
|
|
98
|
+
'send_survey',
|
|
99
|
+
'send_survey_test',
|
|
100
|
+
'reply'
|
|
101
|
+
]);
|
|
102
|
+
const RATE_LIMIT_NOTE = 'Rate limits: 300 requests/minute per IP before authentication, 120/minute per key after it.';
|
|
103
|
+
const SEND_RATE_LIMIT_NOTE = 'Rate limits: 300 requests/minute per IP before authentication, 120/minute per key after it, and 30/minute per key on this operation — it reaches a real handset and bills the org.';
|
|
104
|
+
// ---------------------------------------------------------------------------
|
|
105
|
+
// Shared response shapes
|
|
106
|
+
// ---------------------------------------------------------------------------
|
|
107
|
+
/**
|
|
108
|
+
* The failure envelope every route emits (`middleware/errorHandler.ts`).
|
|
109
|
+
* One schema, referenced by every documented error status, so a client can
|
|
110
|
+
* write one error branch.
|
|
111
|
+
*/
|
|
112
|
+
const ERROR_SCHEMA = {
|
|
113
|
+
type: 'object',
|
|
114
|
+
required: ['success', 'error'],
|
|
115
|
+
properties: {
|
|
116
|
+
success: { type: 'boolean', enum: [false] },
|
|
117
|
+
error: { type: 'string', description: 'Human-readable failure reason.' },
|
|
118
|
+
details: {
|
|
119
|
+
description: 'Present on validation failures: the field-level issues that caused the 400.'
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
};
|
|
123
|
+
/** The `{ success: true, data }` envelope every successful route emits. */
|
|
124
|
+
function successSchema() {
|
|
125
|
+
return {
|
|
126
|
+
type: 'object',
|
|
127
|
+
required: ['success', 'data'],
|
|
128
|
+
properties: {
|
|
129
|
+
success: { type: 'boolean', enum: [true] },
|
|
130
|
+
data: { description: 'The operation result.' }
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
const ERROR_STATUSES = [
|
|
135
|
+
['400', 'The request failed validation.'],
|
|
136
|
+
['401', 'Missing, malformed, revoked or expired credential.'],
|
|
137
|
+
['403', 'The key lacks the required scope, or the org is paused.'],
|
|
138
|
+
['404', 'No such resource in the calling org (a cross-org id reads as absent).'],
|
|
139
|
+
['409', 'Refused by a business rule — e.g. the recipient has opted out.'],
|
|
140
|
+
['429', RATE_LIMIT_NOTE],
|
|
141
|
+
['500', 'Unexpected server error.']
|
|
142
|
+
];
|
|
143
|
+
function errorResponses(toolName) {
|
|
144
|
+
const out = {};
|
|
145
|
+
for (const [status, description] of ERROR_STATUSES) {
|
|
146
|
+
out[status] = {
|
|
147
|
+
description: status === '429' && TIGHTLY_LIMITED.has(toolName)
|
|
148
|
+
? SEND_RATE_LIMIT_NOTE
|
|
149
|
+
: description,
|
|
150
|
+
content: {
|
|
151
|
+
'application/json': {
|
|
152
|
+
schema: { $ref: '#/components/schemas/Error' }
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
return out;
|
|
158
|
+
}
|
|
159
|
+
// ---------------------------------------------------------------------------
|
|
160
|
+
// Webhook payloads
|
|
161
|
+
// ---------------------------------------------------------------------------
|
|
162
|
+
const ISO = { type: 'string', format: 'date-time' };
|
|
163
|
+
const NULLABLE_STRING = { type: ['string', 'null'] };
|
|
164
|
+
/**
|
|
165
|
+
* The `data` shape per event, mirroring `services/webhookEvents.ts` (the one
|
|
166
|
+
* place those payloads are built) and the flow/survey emitters.
|
|
167
|
+
*/
|
|
168
|
+
const WEBHOOK_DATA_SCHEMAS = {
|
|
169
|
+
'survey.completed': {
|
|
170
|
+
type: 'object',
|
|
171
|
+
properties: {
|
|
172
|
+
surveyId: { type: 'string' },
|
|
173
|
+
respondentNumber: { type: 'string', description: 'E.164.' },
|
|
174
|
+
answers: { type: 'object', additionalProperties: true },
|
|
175
|
+
completedAt: ISO
|
|
176
|
+
}
|
|
177
|
+
},
|
|
178
|
+
'response.received': {
|
|
179
|
+
type: 'object',
|
|
180
|
+
properties: {
|
|
181
|
+
surveyId: { type: 'string' },
|
|
182
|
+
respondentNumber: { type: 'string', description: 'E.164.' },
|
|
183
|
+
questionId: { type: 'string' },
|
|
184
|
+
questionIndex: { type: 'integer' },
|
|
185
|
+
answer: {},
|
|
186
|
+
complete: { type: 'boolean' }
|
|
187
|
+
}
|
|
188
|
+
},
|
|
189
|
+
'message.received': {
|
|
190
|
+
type: 'object',
|
|
191
|
+
properties: {
|
|
192
|
+
number: { type: 'string', description: 'E.164.' },
|
|
193
|
+
body: NULLABLE_STRING,
|
|
194
|
+
postbackData: {
|
|
195
|
+
...NULLABLE_STRING,
|
|
196
|
+
description: 'Set when the contact tapped a button rather than typing.'
|
|
197
|
+
},
|
|
198
|
+
conversationId: NULLABLE_STRING,
|
|
199
|
+
occurredAt: ISO
|
|
200
|
+
}
|
|
201
|
+
},
|
|
202
|
+
'message.delivered': {
|
|
203
|
+
type: 'object',
|
|
204
|
+
properties: {
|
|
205
|
+
number: { type: 'string', description: 'E.164.' },
|
|
206
|
+
providerSid: NULLABLE_STRING,
|
|
207
|
+
broadcastId: NULLABLE_STRING,
|
|
208
|
+
status: { type: 'string', enum: ['DELIVERED', 'READ'] },
|
|
209
|
+
channel: NULLABLE_STRING,
|
|
210
|
+
fellBackToSms: { type: 'boolean' },
|
|
211
|
+
occurredAt: ISO,
|
|
212
|
+
source: { type: 'string', enum: ['callback', 'reconcile'] }
|
|
213
|
+
}
|
|
214
|
+
},
|
|
215
|
+
'message.failed': {
|
|
216
|
+
type: 'object',
|
|
217
|
+
properties: {
|
|
218
|
+
number: { type: 'string', description: 'E.164.' },
|
|
219
|
+
providerSid: NULLABLE_STRING,
|
|
220
|
+
broadcastId: NULLABLE_STRING,
|
|
221
|
+
errorCode: {
|
|
222
|
+
...NULLABLE_STRING,
|
|
223
|
+
description: "The PROVIDER's own code, unmodified (e.g. 63049)."
|
|
224
|
+
},
|
|
225
|
+
errorMessage: NULLABLE_STRING,
|
|
226
|
+
channel: NULLABLE_STRING,
|
|
227
|
+
occurredAt: ISO,
|
|
228
|
+
source: { type: 'string', enum: ['callback', 'reconcile'] }
|
|
229
|
+
}
|
|
230
|
+
},
|
|
231
|
+
'broadcast.completed': {
|
|
232
|
+
type: 'object',
|
|
233
|
+
properties: {
|
|
234
|
+
broadcastId: { type: 'string' },
|
|
235
|
+
name: NULLABLE_STRING,
|
|
236
|
+
channel: NULLABLE_STRING,
|
|
237
|
+
status: { type: 'string', enum: ['SENT', 'FAILED'] },
|
|
238
|
+
stats: {
|
|
239
|
+
type: 'object',
|
|
240
|
+
properties: {
|
|
241
|
+
total: { type: 'integer' },
|
|
242
|
+
delivered: { type: 'integer' },
|
|
243
|
+
read: { type: 'integer' },
|
|
244
|
+
failed: { type: 'integer' },
|
|
245
|
+
fellBackToSms: { type: 'integer' }
|
|
246
|
+
}
|
|
247
|
+
},
|
|
248
|
+
completedAt: ISO
|
|
249
|
+
}
|
|
250
|
+
},
|
|
251
|
+
'contact.opted_out': {
|
|
252
|
+
type: 'object',
|
|
253
|
+
properties: {
|
|
254
|
+
number: { type: 'string', description: 'E.164.' },
|
|
255
|
+
body: {
|
|
256
|
+
...NULLABLE_STRING,
|
|
257
|
+
description: 'What the contact actually sent, so the opt-out is auditable.'
|
|
258
|
+
},
|
|
259
|
+
occurredAt: ISO
|
|
260
|
+
}
|
|
261
|
+
},
|
|
262
|
+
'action.tapped': {
|
|
263
|
+
type: 'object',
|
|
264
|
+
properties: {
|
|
265
|
+
number: { type: 'string', description: 'E.164.' },
|
|
266
|
+
postbackData: {
|
|
267
|
+
type: 'string',
|
|
268
|
+
description: 'The stable machine id the author set on the action.'
|
|
269
|
+
},
|
|
270
|
+
text: { ...NULLABLE_STRING, description: 'The chip label the recipient saw.' },
|
|
271
|
+
templateId: NULLABLE_STRING,
|
|
272
|
+
templateName: NULLABLE_STRING,
|
|
273
|
+
configuredUrl: {
|
|
274
|
+
...NULLABLE_STRING,
|
|
275
|
+
description: 'The URL the author configured on the action. The platform never POSTs there itself — route on it from here.'
|
|
276
|
+
},
|
|
277
|
+
occurredAt: ISO
|
|
278
|
+
}
|
|
279
|
+
},
|
|
280
|
+
'flow.started': {
|
|
281
|
+
type: 'object',
|
|
282
|
+
properties: {
|
|
283
|
+
runId: { type: 'string' },
|
|
284
|
+
flowId: { type: 'string' },
|
|
285
|
+
flowName: NULLABLE_STRING,
|
|
286
|
+
kind: { type: 'string', description: 'BOT or JOURNEY.' },
|
|
287
|
+
number: { type: 'string', description: 'E.164.' },
|
|
288
|
+
trigger: { type: 'string' },
|
|
289
|
+
startedAt: ISO
|
|
290
|
+
}
|
|
291
|
+
},
|
|
292
|
+
'flow.completed': {
|
|
293
|
+
type: 'object',
|
|
294
|
+
properties: {
|
|
295
|
+
runId: { type: 'string' },
|
|
296
|
+
flowId: { type: 'string' },
|
|
297
|
+
number: { type: 'string', description: 'E.164.' },
|
|
298
|
+
status: { type: 'string' },
|
|
299
|
+
exitReason: { type: 'string' },
|
|
300
|
+
vars: {
|
|
301
|
+
type: 'object',
|
|
302
|
+
additionalProperties: true,
|
|
303
|
+
description: 'The variables the run collected.'
|
|
304
|
+
},
|
|
305
|
+
endedAt: ISO
|
|
306
|
+
}
|
|
307
|
+
},
|
|
308
|
+
'contact.lead_captured': {
|
|
309
|
+
type: 'object',
|
|
310
|
+
properties: {
|
|
311
|
+
number: { type: 'string', description: 'E.164.' },
|
|
312
|
+
contactId: NULLABLE_STRING,
|
|
313
|
+
source: {
|
|
314
|
+
type: 'string',
|
|
315
|
+
description: 'The normalised source tag — what `Contact.attributes.leadSource` holds.'
|
|
316
|
+
},
|
|
317
|
+
origin: {
|
|
318
|
+
type: 'string',
|
|
319
|
+
enum: ['widget', 'webhook'],
|
|
320
|
+
description: 'How the lead reached us: a click-to-chat `ref:` inbound, or the lead webhook. The shape is identical either way.'
|
|
321
|
+
},
|
|
322
|
+
firstName: NULLABLE_STRING,
|
|
323
|
+
lastName: NULLABLE_STRING,
|
|
324
|
+
email: NULLABLE_STRING,
|
|
325
|
+
optInText: {
|
|
326
|
+
...NULLABLE_STRING,
|
|
327
|
+
description: 'The wording the person agreed to — the legal artefact, verbatim.'
|
|
328
|
+
},
|
|
329
|
+
optInTextIsSystemDefault: {
|
|
330
|
+
type: 'boolean',
|
|
331
|
+
description: 'TRUE when `optInText` is wording the PLATFORM composed because the source supplied none — NOT wording the person was shown. Most integrations map no opt-in wording at all, so this is the common case: file a flagged sentence differently from captured consent.'
|
|
332
|
+
},
|
|
333
|
+
flowStarted: { type: 'boolean' },
|
|
334
|
+
welcomeSent: { type: 'boolean' },
|
|
335
|
+
occurredAt: ISO
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
};
|
|
339
|
+
/** One `webhooks` entry: the signed envelope + headers a receiver must verify. */
|
|
340
|
+
function webhookOperation(event) {
|
|
341
|
+
const data = WEBHOOK_DATA_SCHEMAS[event] ?? {
|
|
342
|
+
type: 'object',
|
|
343
|
+
additionalProperties: true,
|
|
344
|
+
description: 'Event payload. This event has no published shape yet — inspect a delivery.'
|
|
345
|
+
};
|
|
346
|
+
return {
|
|
347
|
+
post: {
|
|
348
|
+
operationId: `webhook_${event.replace(/[^A-Za-z0-9]+/g, '_')}`,
|
|
349
|
+
summary: `${event} event`,
|
|
350
|
+
description: `The platform POSTs this to every active endpoint subscribed to \`${event}\`. ` +
|
|
351
|
+
'Verify `X-RCS-Signature` against the RAW body before trusting it. ' +
|
|
352
|
+
'Respond 2xx to acknowledge; anything else is retried up to 5 attempts with a 1m/5m/25m/60m/60m backoff, and a receiver slower than 8s is aborted.',
|
|
353
|
+
parameters: [
|
|
354
|
+
{
|
|
355
|
+
name: 'X-RCS-Signature',
|
|
356
|
+
in: 'header',
|
|
357
|
+
required: true,
|
|
358
|
+
schema: { type: 'string' },
|
|
359
|
+
description: 't=<unixSeconds>,v1=<hex HMAC-SHA256 of "<t>.<rawBody>"> using the endpoint signing secret.'
|
|
360
|
+
},
|
|
361
|
+
{
|
|
362
|
+
name: 'X-RCS-Event',
|
|
363
|
+
in: 'header',
|
|
364
|
+
required: true,
|
|
365
|
+
schema: { type: 'string', enum: [event] },
|
|
366
|
+
description: 'The event type.'
|
|
367
|
+
},
|
|
368
|
+
{
|
|
369
|
+
name: 'X-RCS-Delivery',
|
|
370
|
+
in: 'header',
|
|
371
|
+
required: true,
|
|
372
|
+
schema: { type: 'string' },
|
|
373
|
+
description: 'Unique delivery id — use it as the idempotency key.'
|
|
374
|
+
}
|
|
375
|
+
],
|
|
376
|
+
requestBody: {
|
|
377
|
+
required: true,
|
|
378
|
+
content: {
|
|
379
|
+
'application/json': {
|
|
380
|
+
schema: {
|
|
381
|
+
type: 'object',
|
|
382
|
+
required: ['id', 'type', 'createdAt', 'data'],
|
|
383
|
+
properties: {
|
|
384
|
+
id: { type: 'string', description: 'Event id (matches X-RCS-Delivery).' },
|
|
385
|
+
type: { type: 'string', enum: [event] },
|
|
386
|
+
createdAt: ISO,
|
|
387
|
+
data
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
},
|
|
393
|
+
responses: {
|
|
394
|
+
'2XX': { description: 'Acknowledged. Anything else is retried.' }
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
};
|
|
398
|
+
}
|
|
399
|
+
// ---------------------------------------------------------------------------
|
|
400
|
+
// Operations
|
|
401
|
+
// ---------------------------------------------------------------------------
|
|
402
|
+
function parameterFor(name, schema, where, required) {
|
|
403
|
+
const { description, ...rest } = schema ?? {};
|
|
404
|
+
return {
|
|
405
|
+
name,
|
|
406
|
+
in: where,
|
|
407
|
+
required,
|
|
408
|
+
...(description ? { description } : {}),
|
|
409
|
+
schema: rest
|
|
410
|
+
};
|
|
411
|
+
}
|
|
412
|
+
function operationFor(tool) {
|
|
413
|
+
const params = new Set(pathParamsOf(tool.path));
|
|
414
|
+
const properties = tool.inputSchema.properties ?? {};
|
|
415
|
+
const required = new Set(tool.inputSchema.required ?? []);
|
|
416
|
+
const hasBody = tool.method === 'POST' || tool.method === 'PUT' || tool.method === 'PATCH';
|
|
417
|
+
const parameters = [];
|
|
418
|
+
const bodyProps = {};
|
|
419
|
+
const bodyRequired = [];
|
|
420
|
+
for (const [name, raw] of Object.entries(properties)) {
|
|
421
|
+
const schema = (raw ?? {});
|
|
422
|
+
if (params.has(name)) {
|
|
423
|
+
// A path parameter is required by construction — the URL cannot be built
|
|
424
|
+
// without it, whatever the inputSchema happens to say.
|
|
425
|
+
parameters.push(parameterFor(name, schema, 'path', true));
|
|
426
|
+
continue;
|
|
427
|
+
}
|
|
428
|
+
if (hasBody) {
|
|
429
|
+
bodyProps[name] = schema;
|
|
430
|
+
if (required.has(name))
|
|
431
|
+
bodyRequired.push(name);
|
|
432
|
+
}
|
|
433
|
+
else {
|
|
434
|
+
parameters.push(parameterFor(name, schema, 'query', required.has(name)));
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
const successStatus = String(tool.successStatus ?? 200);
|
|
438
|
+
const operation = {
|
|
439
|
+
operationId: tool.name,
|
|
440
|
+
tags: [tagFor(tool)],
|
|
441
|
+
summary: firstSentence(tool.description),
|
|
442
|
+
description: tool.description,
|
|
443
|
+
...(parameters.length ? { parameters } : {}),
|
|
444
|
+
responses: {
|
|
445
|
+
[successStatus]: {
|
|
446
|
+
description: 'Success.',
|
|
447
|
+
content: { 'application/json': { schema: successSchema() } }
|
|
448
|
+
},
|
|
449
|
+
...errorResponses(tool.name)
|
|
450
|
+
}
|
|
451
|
+
};
|
|
452
|
+
// `(none)` is the scope string the catalog uses for the discovery endpoints.
|
|
453
|
+
// An empty `security` array is OpenAPI's way of saying "no requirement", and
|
|
454
|
+
// is the honest rendering of a route with no `requireScope`.
|
|
455
|
+
operation.security =
|
|
456
|
+
tool.scope === '(none)'
|
|
457
|
+
? []
|
|
458
|
+
: [{ bearerAuth: [tool.scope] }, { oauth2: [tool.scope] }];
|
|
459
|
+
if (hasBody && Object.keys(bodyProps).length > 0) {
|
|
460
|
+
const schemaName = pascal(tool.name);
|
|
461
|
+
operation.requestBody = {
|
|
462
|
+
required: bodyRequired.length > 0,
|
|
463
|
+
content: {
|
|
464
|
+
'application/json': {
|
|
465
|
+
schema: { $ref: `#/components/schemas/${schemaName}` }
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
};
|
|
469
|
+
operation['x-request-schema'] = schemaName;
|
|
470
|
+
}
|
|
471
|
+
return operation;
|
|
472
|
+
}
|
|
473
|
+
/**
|
|
474
|
+
* The component schema for a tool's request body — its `inputSchema` minus the
|
|
475
|
+
* properties that are carried in the URL. GET/DELETE tools get none: their
|
|
476
|
+
* whole input expands into parameters, so a component would be dead weight.
|
|
477
|
+
*/
|
|
478
|
+
function requestSchemaFor(tool) {
|
|
479
|
+
const hasBody = tool.method === 'POST' || tool.method === 'PUT' || tool.method === 'PATCH';
|
|
480
|
+
if (!hasBody)
|
|
481
|
+
return null;
|
|
482
|
+
const params = new Set(pathParamsOf(tool.path));
|
|
483
|
+
const properties = {};
|
|
484
|
+
for (const [name, schema] of Object.entries(tool.inputSchema.properties ?? {})) {
|
|
485
|
+
if (!params.has(name))
|
|
486
|
+
properties[name] = schema;
|
|
487
|
+
}
|
|
488
|
+
if (Object.keys(properties).length === 0)
|
|
489
|
+
return null;
|
|
490
|
+
const required = (tool.inputSchema.required ?? []).filter((name) => !params.has(name));
|
|
491
|
+
return {
|
|
492
|
+
type: 'object',
|
|
493
|
+
properties,
|
|
494
|
+
...(required.length ? { required } : {}),
|
|
495
|
+
...(tool.inputSchema.additionalProperties === false
|
|
496
|
+
? { additionalProperties: false }
|
|
497
|
+
: {})
|
|
498
|
+
};
|
|
499
|
+
}
|
|
500
|
+
// ---------------------------------------------------------------------------
|
|
501
|
+
// The document
|
|
502
|
+
// ---------------------------------------------------------------------------
|
|
503
|
+
/**
|
|
504
|
+
* Build the gateway's OpenAPI 3.1 document.
|
|
505
|
+
*
|
|
506
|
+
* `servers` is supplied by the caller because only the server knows which
|
|
507
|
+
* environment it is: a published spec whose host is a placeholder cannot be
|
|
508
|
+
* imported into Postman or a code generator without hand-editing, which is the
|
|
509
|
+
* exact defect this replaces.
|
|
510
|
+
*/
|
|
511
|
+
function buildOpenApiDocument(servers, options = {}) {
|
|
512
|
+
const events = options.webhookEvents ?? tools_1.WEBHOOK_EVENT_NAMES;
|
|
513
|
+
const paths = {};
|
|
514
|
+
const schemas = { Error: ERROR_SCHEMA };
|
|
515
|
+
for (const tool of tools_1.TOOLS) {
|
|
516
|
+
const url = toOpenApiPath(tool.path);
|
|
517
|
+
const item = (paths[url] ??= {});
|
|
518
|
+
const verb = tool.method.toLowerCase();
|
|
519
|
+
if (item[verb]) {
|
|
520
|
+
throw new Error(`Two tools claim ${tool.method} ${tool.path} (${item[verb].operationId} and ${tool.name})`);
|
|
521
|
+
}
|
|
522
|
+
item[verb] = operationFor(tool);
|
|
523
|
+
const requestSchema = requestSchemaFor(tool);
|
|
524
|
+
if (requestSchema)
|
|
525
|
+
schemas[pascal(tool.name)] = requestSchema;
|
|
526
|
+
}
|
|
527
|
+
const webhooks = {};
|
|
528
|
+
for (const event of events)
|
|
529
|
+
webhooks[event] = webhookOperation(event);
|
|
530
|
+
return {
|
|
531
|
+
openapi: '3.1.0',
|
|
532
|
+
info: {
|
|
533
|
+
title: 'RCS Campaigns agent gateway',
|
|
534
|
+
version: options.version ?? '1.0.0',
|
|
535
|
+
description: 'The machine-facing API behind the MCP server, the CLI and the skill chains. ' +
|
|
536
|
+
'Authenticate with a scoped `rcsk_live_…` agent key, or an OAuth 2.1 access token (`rcso_at_…`) from the remote MCP server. ' +
|
|
537
|
+
'Every response is the envelope `{ success: true, data }` or `{ success: false, error }`. ' +
|
|
538
|
+
'Generated from the shared tool catalog — the same list the MCP server and CLI run on — so it cannot drift from the implementation.',
|
|
539
|
+
license: { name: 'Proprietary', identifier: 'LicenseRef-Proprietary' }
|
|
540
|
+
},
|
|
541
|
+
servers,
|
|
542
|
+
// The default requirement; each operation restates its own scope, and the
|
|
543
|
+
// discovery endpoints override it with an empty array.
|
|
544
|
+
security: [{ bearerAuth: [] }, { oauth2: [] }],
|
|
545
|
+
tags: [
|
|
546
|
+
{ name: 'Discovery', description: 'What this key may do, and this document.' },
|
|
547
|
+
{ name: 'Templates', description: 'Author, render, submit and clone templates.' },
|
|
548
|
+
{ name: 'Surveys', description: 'Author, submit, send and read conversational surveys.' },
|
|
549
|
+
{ name: 'Messages', description: 'Send an approved template to a list of numbers.' },
|
|
550
|
+
{ name: 'Conversations', description: 'Read threads, reply into them, hand them over.' },
|
|
551
|
+
{ name: 'Contacts', description: 'The org-level person record behind every audience.' },
|
|
552
|
+
{ name: 'Audiences', description: 'Saved contact lists.' },
|
|
553
|
+
{ name: 'Webhooks', description: 'Subscribe to signed platform events.' },
|
|
554
|
+
{ name: 'Media', description: 'Host an image on the platform bucket.' },
|
|
555
|
+
{ name: 'Usage', description: 'Month-to-date metered usage.' }
|
|
556
|
+
],
|
|
557
|
+
paths,
|
|
558
|
+
webhooks,
|
|
559
|
+
components: {
|
|
560
|
+
securitySchemes: {
|
|
561
|
+
bearerAuth: {
|
|
562
|
+
type: 'http',
|
|
563
|
+
scheme: 'bearer',
|
|
564
|
+
description: 'A scoped agent key (`rcsk_live_…`), minted at Settings → API keys and shown exactly once. Send it as `Authorization: Bearer …`.'
|
|
565
|
+
},
|
|
566
|
+
oauth2: {
|
|
567
|
+
type: 'oauth2',
|
|
568
|
+
description: 'The remote MCP server is an OAuth 2.1 authorization server with PKCE and Dynamic Client Registration. Access tokens look like `rcso_at_…` and resolve to the same org + scopes as a key.',
|
|
569
|
+
flows: {
|
|
570
|
+
authorizationCode: {
|
|
571
|
+
authorizationUrl: 'https://mcp.rcs-templates.com/oauth/authorize',
|
|
572
|
+
tokenUrl: 'https://mcp.rcs-templates.com/oauth/token',
|
|
573
|
+
refreshUrl: 'https://mcp.rcs-templates.com/oauth/token',
|
|
574
|
+
scopes: Object.fromEntries([...new Set(tools_1.TOOLS.map((t) => t.scope))]
|
|
575
|
+
.filter((scope) => scope !== '(none)')
|
|
576
|
+
.sort()
|
|
577
|
+
.map((scope) => [scope, scopeSummary(scope)]))
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
},
|
|
582
|
+
schemas
|
|
583
|
+
}
|
|
584
|
+
};
|
|
585
|
+
}
|
|
586
|
+
/** One line per scope for the OAuth flow's `scopes` map. */
|
|
587
|
+
function scopeSummary(scope) {
|
|
588
|
+
const names = tools_1.TOOLS.filter((t) => t.scope === scope).map((t) => t.name);
|
|
589
|
+
return names.join(', ');
|
|
590
|
+
}
|
package/dist/tools.d.ts
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The shared agent tool catalog.
|
|
3
|
+
*
|
|
4
|
+
* One declarative list of the gateway's capabilities — name, description, the
|
|
5
|
+
* JSON-Schema for inputs (used verbatim as the MCP `inputSchema`), and a pure
|
|
6
|
+
* function that turns validated args into a gateway RequestSpec. Both the MCP
|
|
7
|
+
* server (tools/list + tools/call) and the CLI build on this list, so adding a
|
|
8
|
+
* capability means editing ONE place and both skins gain it.
|
|
9
|
+
*/
|
|
10
|
+
import { type Channel, type RequestSpec } from './client';
|
|
11
|
+
/** The HTTP verbs the gateway routes are registered under. */
|
|
12
|
+
export type ToolMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
|
|
13
|
+
export interface ToolDef {
|
|
14
|
+
name: string;
|
|
15
|
+
description: string;
|
|
16
|
+
/** The gateway scope a key needs for this tool (informational/discovery). */
|
|
17
|
+
scope: string;
|
|
18
|
+
/**
|
|
19
|
+
* STATIC route metadata — the verb and the Express-param path this tool calls
|
|
20
|
+
* (`/conversations/:id`), relative to the gateway base.
|
|
21
|
+
*
|
|
22
|
+
* `toRequest` below builds a CONCRETE request for concrete args, so it can
|
|
23
|
+
* never answer "what shape is this endpoint?" without being invoked. These
|
|
24
|
+
* two fields can, which is what lets ONE catalog back the MCP server, the
|
|
25
|
+
* CLI, `GET /api/agent/capabilities`, the generated OpenAPI document, and the
|
|
26
|
+
* route-coverage test that fails CI when a gateway route has no entry here.
|
|
27
|
+
*
|
|
28
|
+
* The invariant the OpenAPI generator depends on, pinned by the toolkit's own
|
|
29
|
+
* tests: every `:param` in `path` is also a property of `inputSchema`.
|
|
30
|
+
*/
|
|
31
|
+
method: ToolMethod;
|
|
32
|
+
path: string;
|
|
33
|
+
/**
|
|
34
|
+
* The status the route returns on success — 200 unless the call CREATES a
|
|
35
|
+
* row, which returns 201. Static for the same reason `method`/`path` are:
|
|
36
|
+
* the generated OpenAPI document has to name a success code, and guessing
|
|
37
|
+
* "POST ⇒ 201" would be wrong for `submit_template`, `send_survey` and every
|
|
38
|
+
* other POST-as-action on this gateway.
|
|
39
|
+
*/
|
|
40
|
+
successStatus?: 200 | 201;
|
|
41
|
+
/** JSON Schema for the tool arguments (MCP inputSchema, draft-07 style). */
|
|
42
|
+
inputSchema: {
|
|
43
|
+
type: 'object';
|
|
44
|
+
properties: Record<string, unknown>;
|
|
45
|
+
required?: string[];
|
|
46
|
+
additionalProperties?: boolean;
|
|
47
|
+
};
|
|
48
|
+
/** Build the HTTP request for these (already MCP-validated) args. */
|
|
49
|
+
toRequest: (args: Record<string, any>) => RequestSpec;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* The outbound webhook event types an endpoint may subscribe to — THE list,
|
|
53
|
+
* not a copy of one.
|
|
54
|
+
*
|
|
55
|
+
* It lives here because this is the package both ends can reach: the toolkit
|
|
56
|
+
* is a standalone npm dependency and cannot import from `apps/server`, but
|
|
57
|
+
* `apps/server` already depends on the toolkit (`workspace:*`) for `TOOLS` and
|
|
58
|
+
* the OpenAPI generator, so the arrow only points one way. `services/webhooks.ts`
|
|
59
|
+
* re-exports this array as `WEBHOOK_EVENTS`; there is no second array to keep
|
|
60
|
+
* in step, and **adding an event means editing this one list**.
|
|
61
|
+
*
|
|
62
|
+
* It used to be hand-listed here and hand-listed again in the server, kept
|
|
63
|
+
* honest by an exact-equality test across the two packages. That test could
|
|
64
|
+
* only fail AFTER a merge — a branch adding an eleventh event to one list and
|
|
65
|
+
* a branch adding the enum to the other are each green alone and red together,
|
|
66
|
+
* and `deploy.yml` runs the server suite before deploying, so the desync would
|
|
67
|
+
* have blocked the backend deploy rather than merely CI. Deriving removes the
|
|
68
|
+
* failure mode instead of detecting it later.
|
|
69
|
+
*
|
|
70
|
+
* The values are persisted on `WebhookEndpoint.events`, so a name here is a
|
|
71
|
+
* contract with every endpoint already registered: ADD, never rename.
|
|
72
|
+
*
|
|
73
|
+
* `contact.lead_captured` (MSG-1832) is the eleventh, appended never renamed.
|
|
74
|
+
*/
|
|
75
|
+
export declare const WEBHOOK_EVENT_NAMES: readonly ["survey.completed", "response.received", "message.received", "message.delivered", "message.failed", "broadcast.completed", "contact.opted_out", "action.tapped", "flow.started", "flow.completed", "payment.completed", "payment.failed", "contact.lead_captured"];
|
|
76
|
+
export declare const TOOLS: ToolDef[];
|
|
77
|
+
/** Look up a tool by name. */
|
|
78
|
+
export declare function findTool(name: string): ToolDef | undefined;
|
|
79
|
+
/** Does this tool accept a `channel` argument at all? */
|
|
80
|
+
export declare function acceptsChannel(tool: ToolDef): boolean;
|
|
81
|
+
/**
|
|
82
|
+
* Fill in the skin's default channel for a call that accepts one and did not
|
|
83
|
+
* specify it.
|
|
84
|
+
*
|
|
85
|
+
* Both skins run the same catalog against the same gateway, so the only thing
|
|
86
|
+
* that should differ is which channel you get when you don't ask: the `wa` bins
|
|
87
|
+
* assume WhatsApp, the `rcs` bins stay silent and let the server fall back to
|
|
88
|
+
* the org's own brand channel. An explicit `channel` from the caller always
|
|
89
|
+
* wins, and tools that take no channel are returned untouched — so this can be
|
|
90
|
+
* applied unconditionally at the CLI/MCP boundary.
|
|
91
|
+
*/
|
|
92
|
+
export declare function withDefaultChannel(tool: ToolDef, args: Record<string, any>, defaultChannel?: Channel): Record<string, any>;
|
|
93
|
+
//# sourceMappingURL=tools.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"tools.d.ts","sourceRoot":"","sources":["../src/tools.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AACH,OAAO,EAAoB,KAAK,OAAO,EAAE,KAAK,WAAW,EAAE,MAAM,UAAU,CAAC;AAE5E,8DAA8D;AAC9D,MAAM,MAAM,UAAU,GAAG,KAAK,GAAG,MAAM,GAAG,KAAK,GAAG,OAAO,GAAG,QAAQ,CAAC;AAErE,MAAM,WAAW,OAAO;IACtB,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,EAAE,MAAM,CAAC;IACpB,6EAA6E;IAC7E,KAAK,EAAE,MAAM,CAAC;IACd;;;;;;;;;;;;OAYG;IACH,MAAM,EAAE,UAAU,CAAC;IACnB,IAAI,EAAE,MAAM,CAAC;IACb;;;;;;OAMG;IACH,aAAa,CAAC,EAAE,GAAG,GAAG,GAAG,CAAC;IAC1B,4EAA4E;IAC5E,WAAW,EAAE;QACX,IAAI,EAAE,QAAQ,CAAC;QACf,UAAU,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;QACpC,QAAQ,CAAC,EAAE,MAAM,EAAE,CAAC;QACpB,oBAAoB,CAAC,EAAE,OAAO,CAAC;KAChC,CAAC;IACF,qEAAqE;IACrE,SAAS,EAAE,CAAC,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,KAAK,WAAW,CAAC;CACvD;AAmBD;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,eAAO,MAAM,mBAAmB,8QA+BtB,CAAC;AAEX,eAAO,MAAM,KAAK,EAAE,OAAO,EA4uC1B,CAAC;AAEF,8BAA8B;AAC9B,wBAAgB,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,GAAG,SAAS,CAE1D;AAED,yDAAyD;AACzD,wBAAgB,cAAc,CAAC,IAAI,EAAE,OAAO,GAAG,OAAO,CAKrD;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,kBAAkB,CAChC,IAAI,EAAE,OAAO,EACb,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,EACzB,cAAc,CAAC,EAAE,OAAO,GACvB,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAcrB"}
|