@motorical/mcp 1.2.1 → 1.3.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/package.json +1 -1
- package/src/client.js +122 -0
- package/src/server.js +153 -1
package/package.json
CHANGED
package/src/client.js
CHANGED
|
@@ -208,6 +208,128 @@ export class MotoricalClient {
|
|
|
208
208
|
return `${path}${sep}motorBlockId=${encodeURIComponent(id)}`;
|
|
209
209
|
}
|
|
210
210
|
|
|
211
|
+
/**
|
|
212
|
+
* Query string from a params object, skipping anything the caller didn't
|
|
213
|
+
* supply. Returns '' when nothing survives, so it composes with _scoped()
|
|
214
|
+
* and _accountPath() — both of which detect an existing '?' and switch to
|
|
215
|
+
* '&' when appending motorBlockId.
|
|
216
|
+
*/
|
|
217
|
+
_qs(params = {}) {
|
|
218
|
+
const q = new URLSearchParams();
|
|
219
|
+
for (const [k, v] of Object.entries(params)) {
|
|
220
|
+
if (v === undefined || v === null || v === '') continue;
|
|
221
|
+
q.set(k, String(v));
|
|
222
|
+
}
|
|
223
|
+
const s = q.toString();
|
|
224
|
+
return s ? `?${s}` : '';
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
async getOverview({ motorBlockId, from, to } = {}) {
|
|
228
|
+
const bearer = await this.getBearer({ motorBlockId });
|
|
229
|
+
const path = `/api/public/v1/motor-blocks/${encodeURIComponent(motorBlockId)}/overview`;
|
|
230
|
+
return this.request('GET', this._scoped(path + this._qs({ from, to }), motorBlockId), { bearer });
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
async getDailySummary({ motorBlockId, days } = {}) {
|
|
234
|
+
const bearer = await this.getBearer({ motorBlockId });
|
|
235
|
+
const path = `/api/public/v1/motor-blocks/${encodeURIComponent(motorBlockId)}/daily-summary`;
|
|
236
|
+
return this.request('GET', this._scoped(path + this._qs({ days }), motorBlockId), { bearer });
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
async getMetrics({ motorBlockId, from, to, interval } = {}) {
|
|
240
|
+
const bearer = await this.getBearer({ motorBlockId });
|
|
241
|
+
const path = `/api/public/v1/motor-blocks/${encodeURIComponent(motorBlockId)}/metrics`;
|
|
242
|
+
return this.request('GET', this._scoped(path + this._qs({ from, to, interval }), motorBlockId), { bearer });
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
async getDeliverability({ motorBlockId, from, to, limit } = {}) {
|
|
246
|
+
const bearer = await this.getBearer({ motorBlockId });
|
|
247
|
+
const path = `/api/public/v1/motor-blocks/${encodeURIComponent(motorBlockId)}/deliverability`;
|
|
248
|
+
return this.request('GET', this._scoped(path + this._qs({ from, to, limit }), motorBlockId), { bearer });
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
async getReputation({ motorBlockId } = {}) {
|
|
252
|
+
const bearer = await this.getBearer({ motorBlockId });
|
|
253
|
+
const path = `/api/public/v1/motor-blocks/${encodeURIComponent(motorBlockId)}/reputation`;
|
|
254
|
+
return this.request('GET', this._scoped(path, motorBlockId), { bearer });
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
async getAnomalies({ motorBlockId } = {}) {
|
|
258
|
+
const bearer = await this.getBearer({ motorBlockId });
|
|
259
|
+
const path = `/api/public/v1/motor-blocks/${encodeURIComponent(motorBlockId)}/anomalies`;
|
|
260
|
+
return this.request('GET', this._scoped(path, motorBlockId), { bearer });
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
async getProviders({ motorBlockId, from, to, limit } = {}) {
|
|
264
|
+
const bearer = await this.getBearer({ motorBlockId });
|
|
265
|
+
const path = `/api/public/v1/motor-blocks/${encodeURIComponent(motorBlockId)}/providers`;
|
|
266
|
+
return this.request('GET', this._scoped(path + this._qs({ from, to, limit }), motorBlockId), { bearer });
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
async getErrorCodes({ motorBlockId, from, to, limit } = {}) {
|
|
270
|
+
const bearer = await this.getBearer({ motorBlockId });
|
|
271
|
+
const path = `/api/public/v1/motor-blocks/${encodeURIComponent(motorBlockId)}/error-codes`;
|
|
272
|
+
return this.request('GET', this._scoped(path + this._qs({ from, to, limit }), motorBlockId), { bearer });
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
async getRateLimits({ motorBlockId } = {}) {
|
|
276
|
+
const bearer = await this.getBearer({ motorBlockId });
|
|
277
|
+
const path = `/api/public/v1/motor-blocks/${encodeURIComponent(motorBlockId)}/rate-limits`;
|
|
278
|
+
return this.request('GET', this._scoped(path, motorBlockId), { bearer });
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
async getConfig({ motorBlockId } = {}) {
|
|
282
|
+
const bearer = await this.getBearer({ motorBlockId });
|
|
283
|
+
const path = `/api/public/v1/motor-blocks/${encodeURIComponent(motorBlockId)}/config`;
|
|
284
|
+
return this.request('GET', this._scoped(path, motorBlockId), { bearer });
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// refresh is sent only when true: the route reads it as "re-run the live DNS
|
|
288
|
+
// check now", so refresh=false is not the same request as omitting it.
|
|
289
|
+
async getDomainHealth({ motorBlockId, refresh } = {}) {
|
|
290
|
+
const bearer = await this.getBearer({ motorBlockId });
|
|
291
|
+
const path = `/api/public/v1/motor-blocks/${encodeURIComponent(motorBlockId)}/domain-health`;
|
|
292
|
+
const qs = this._qs({ refresh: refresh === true ? 'true' : undefined });
|
|
293
|
+
return this.request('GET', this._scoped(path + qs, motorBlockId), { bearer });
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// Account-wide: the route is mounted { accountScoped: true } and reads only
|
|
297
|
+
// the token's user, so _accountPath omits the block rather than throwing.
|
|
298
|
+
// Using _scoped() here would demand a Motor Block for an account-wide route.
|
|
299
|
+
async getAccountRateLimits({ motorBlockId } = {}) {
|
|
300
|
+
const bearer = await this.getBearer({ motorBlockId });
|
|
301
|
+
return this.request(
|
|
302
|
+
'GET',
|
|
303
|
+
this._accountPath('/api/public/v1/account/rate-limits', motorBlockId),
|
|
304
|
+
{ bearer }
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
async getLogs({
|
|
309
|
+
motorBlockId, from, to, currentOutcome, query, limit, cursor, includePII,
|
|
310
|
+
} = {}) {
|
|
311
|
+
const bearer = await this.getBearer({ motorBlockId });
|
|
312
|
+
const path = `/api/public/v1/motor-blocks/${encodeURIComponent(motorBlockId)}/logs`;
|
|
313
|
+
const qs = this._qs({
|
|
314
|
+
from, to, currentOutcome, query, limit, cursor,
|
|
315
|
+
includePII: includePII === true ? 'true' : undefined,
|
|
316
|
+
});
|
|
317
|
+
return this.request('GET', this._scoped(path + qs, motorBlockId), { bearer });
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// GET /messages is a LOOKUP, not a listing: it 400s without an exact
|
|
321
|
+
// smtpMessageId and resolves at most one match. This is a second lookup key
|
|
322
|
+
// alongside getMessage()'s internal UUID, never a way to browse.
|
|
323
|
+
async getMessageBySmtpId({ smtpMessageId, motorBlockId, includePII } = {}) {
|
|
324
|
+
if (!smtpMessageId) throw new Error('smtpMessageId is required');
|
|
325
|
+
const bearer = await this.getBearer({ motorBlockId });
|
|
326
|
+
const qs = this._qs({
|
|
327
|
+
smtpMessageId,
|
|
328
|
+
includePII: includePII === true ? 'true' : undefined,
|
|
329
|
+
});
|
|
330
|
+
return this.request('GET', this._scoped(`/api/public/v1/messages${qs}`, motorBlockId), { bearer });
|
|
331
|
+
}
|
|
332
|
+
|
|
211
333
|
async getBearer({ motorBlockId, forceRefresh = false } = {}) {
|
|
212
334
|
// An OAuth grant supersedes minted public tokens entirely — no ak_live_ key
|
|
213
335
|
// and no dashboard JWT needed.
|
package/src/server.js
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
1
2
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
3
|
import { z } from 'zod';
|
|
3
4
|
import { MotoricalClient, loadConfig } from './client.js';
|
|
4
5
|
|
|
5
|
-
const PACKAGE_VERSION =
|
|
6
|
+
const PACKAGE_VERSION = JSON.parse(
|
|
7
|
+
readFileSync(new URL('../package.json', import.meta.url))
|
|
8
|
+
).version;
|
|
6
9
|
|
|
7
10
|
function jsonResult(data, { isError = false } = {}) {
|
|
8
11
|
return {
|
|
@@ -404,6 +407,155 @@ export function createMotoricalMcpServer(options = {}) {
|
|
|
404
407
|
}
|
|
405
408
|
);
|
|
406
409
|
|
|
410
|
+
// ---- Analytics & health tools ----
|
|
411
|
+
// Each wraps one existing Public API route one-to-one. See
|
|
412
|
+
// motorical-docs/plans/2026-09-02-mcp-analytics-health-tools-implementation.md.
|
|
413
|
+
// .optional() on purpose, matching every pre-existing block-scoped tool: a
|
|
414
|
+
// grant covering exactly one Motor Block resolves it automatically, so the
|
|
415
|
+
// caller only has to name one when the grant covers several. Making it
|
|
416
|
+
// required forces single-block users to supply an id they should never need,
|
|
417
|
+
// and turns the multi-block case into a Zod type error instead of
|
|
418
|
+
// resolveBlock's actionable message.
|
|
419
|
+
const blockSelector = z.string().uuid().optional()
|
|
420
|
+
.describe('Required when the authorization covers more than one Motor Block');
|
|
421
|
+
const isoDate = (bound) => z.string().optional().describe(`ISO date or datetime, ${bound}`);
|
|
422
|
+
|
|
423
|
+
registerTool(
|
|
424
|
+
'motorical_get_overview',
|
|
425
|
+
{
|
|
426
|
+
description:
|
|
427
|
+
'Sending overview for one Motor Block over a date range — volume, delivery and bounce '
|
|
428
|
+
+ 'rates, and current usage against plan limits (GET /api/public/v1/motor-blocks/{id}/overview).',
|
|
429
|
+
inputSchema: {
|
|
430
|
+
motorBlockId: blockSelector,
|
|
431
|
+
from: isoDate('inclusive'),
|
|
432
|
+
to: isoDate('inclusive')
|
|
433
|
+
}
|
|
434
|
+
},
|
|
435
|
+
async (args) => {
|
|
436
|
+
try {
|
|
437
|
+
return jsonResult(await client.getOverview(args));
|
|
438
|
+
} catch (err) {
|
|
439
|
+
return errorResult(err);
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
);
|
|
443
|
+
|
|
444
|
+
registerTool('motorical_get_daily_summary', {
|
|
445
|
+
description: 'Per-day send counts and outcomes for one Motor Block (GET /api/public/v1/motor-blocks/{id}/daily-summary).',
|
|
446
|
+
inputSchema: {
|
|
447
|
+
motorBlockId: blockSelector,
|
|
448
|
+
days: z.number().int().positive().optional().describe('How many days back, counting today')
|
|
449
|
+
}
|
|
450
|
+
}, async (args) => { try { return jsonResult(await client.getDailySummary(args)); } catch (err) { return errorResult(err); } });
|
|
451
|
+
|
|
452
|
+
registerTool('motorical_get_metrics', {
|
|
453
|
+
description: 'Time-series send metrics for one Motor Block, bucketed by hour or day (GET /api/public/v1/motor-blocks/{id}/metrics).',
|
|
454
|
+
inputSchema: {
|
|
455
|
+
motorBlockId: blockSelector,
|
|
456
|
+
from: isoDate('inclusive'),
|
|
457
|
+
to: isoDate('inclusive'),
|
|
458
|
+
interval: z.enum(['hour', 'day']).optional().describe('Bucket size; defaults to the API default')
|
|
459
|
+
}
|
|
460
|
+
}, async (args) => { try { return jsonResult(await client.getMetrics(args)); } catch (err) { return errorResult(err); } });
|
|
461
|
+
|
|
462
|
+
registerTool('motorical_get_deliverability', {
|
|
463
|
+
description: 'Deliverability broken down by recipient domain (GET /api/public/v1/motor-blocks/{id}/deliverability).',
|
|
464
|
+
inputSchema: {
|
|
465
|
+
motorBlockId: blockSelector,
|
|
466
|
+
from: isoDate('inclusive'),
|
|
467
|
+
to: isoDate('inclusive'),
|
|
468
|
+
limit: z.number().int().positive().optional().describe('Max recipient domains returned')
|
|
469
|
+
}
|
|
470
|
+
}, async (args) => { try { return jsonResult(await client.getDeliverability(args)); } catch (err) { return errorResult(err); } });
|
|
471
|
+
|
|
472
|
+
registerTool('motorical_get_reputation', {
|
|
473
|
+
description: 'Current sending reputation for one Motor Block (GET /api/public/v1/motor-blocks/{id}/reputation).',
|
|
474
|
+
inputSchema: { motorBlockId: blockSelector }
|
|
475
|
+
}, async (args) => { try { return jsonResult(await client.getReputation(args)); } catch (err) { return errorResult(err); } });
|
|
476
|
+
|
|
477
|
+
registerTool('motorical_get_anomalies', {
|
|
478
|
+
description: 'Detected sending anomalies for one Motor Block — volume spikes, bounce surges, unusual patterns (GET /api/public/v1/motor-blocks/{id}/anomalies).',
|
|
479
|
+
inputSchema: { motorBlockId: blockSelector }
|
|
480
|
+
}, async (args) => { try { return jsonResult(await client.getAnomalies(args)); } catch (err) { return errorResult(err); } });
|
|
481
|
+
|
|
482
|
+
registerTool('motorical_get_providers', {
|
|
483
|
+
description: 'Send outcomes grouped by receiving mailbox provider (GET /api/public/v1/motor-blocks/{id}/providers).',
|
|
484
|
+
inputSchema: {
|
|
485
|
+
motorBlockId: blockSelector,
|
|
486
|
+
from: isoDate('inclusive'),
|
|
487
|
+
to: isoDate('inclusive'),
|
|
488
|
+
limit: z.number().int().positive().optional().describe('Max providers returned')
|
|
489
|
+
}
|
|
490
|
+
}, async (args) => { try { return jsonResult(await client.getProviders(args)); } catch (err) { return errorResult(err); } });
|
|
491
|
+
|
|
492
|
+
registerTool('motorical_get_error_codes', {
|
|
493
|
+
description: 'SMTP error codes seen for one Motor Block, with counts and diagnostics (GET /api/public/v1/motor-blocks/{id}/error-codes).',
|
|
494
|
+
inputSchema: {
|
|
495
|
+
motorBlockId: blockSelector,
|
|
496
|
+
from: isoDate('inclusive'),
|
|
497
|
+
to: isoDate('inclusive'),
|
|
498
|
+
limit: z.number().int().positive().optional().describe('Max distinct error codes returned')
|
|
499
|
+
}
|
|
500
|
+
}, async (args) => { try { return jsonResult(await client.getErrorCodes(args)); } catch (err) { return errorResult(err); } });
|
|
501
|
+
|
|
502
|
+
registerTool('motorical_get_rate_limits', {
|
|
503
|
+
description: "Current hourly and daily send usage against this Motor Block's limits (GET /api/public/v1/motor-blocks/{id}/rate-limits).",
|
|
504
|
+
inputSchema: { motorBlockId: blockSelector }
|
|
505
|
+
}, async (args) => { try { return jsonResult(await client.getRateLimits(args)); } catch (err) { return errorResult(err); } });
|
|
506
|
+
|
|
507
|
+
registerTool('motorical_get_account_rate_limits', {
|
|
508
|
+
description:
|
|
509
|
+
'Account-wide send ceiling and current usage across every Motor Block '
|
|
510
|
+
+ '(GET /api/public/v1/account/rate-limits). Account-scoped: no Motor Block needed.',
|
|
511
|
+
inputSchema: {
|
|
512
|
+
motorBlockId: z.string().uuid().optional()
|
|
513
|
+
.describe('Optional. This operation acts on the whole account, so a Motor Block is never needed; pass one only to record which block the call was made on behalf of.')
|
|
514
|
+
}
|
|
515
|
+
}, async (args) => { try { return jsonResult(await client.getAccountRateLimits(args)); } catch (err) { return errorResult(err); } });
|
|
516
|
+
|
|
517
|
+
registerTool('motorical_get_logs', {
|
|
518
|
+
description:
|
|
519
|
+
'Search send logs for one Motor Block (GET /api/public/v1/motor-blocks/{id}/logs). '
|
|
520
|
+
+ 'Paginate with cursor. Recipient addresses are masked unless the token carries logs.pii, '
|
|
521
|
+
+ 'which OAuth tokens never do.',
|
|
522
|
+
inputSchema: {
|
|
523
|
+
motorBlockId: blockSelector,
|
|
524
|
+
from: isoDate('inclusive'),
|
|
525
|
+
to: isoDate('inclusive'),
|
|
526
|
+
currentOutcome: z.string().optional().describe('Filter to one delivery outcome, e.g. delivered, bounced, deferred'),
|
|
527
|
+
query: z.string().optional().describe('Free-text match against recipient, subject or message id'),
|
|
528
|
+
limit: z.number().int().positive().optional().describe('Page size'),
|
|
529
|
+
cursor: z.string().optional().describe('Opaque cursor from a previous page'),
|
|
530
|
+
includePII: z.boolean().optional().describe('Unmask recipient addresses. Requires the logs.pii scope, which is never granted to OAuth tokens — the API returns 403.')
|
|
531
|
+
}
|
|
532
|
+
}, async (args) => { try { return jsonResult(await client.getLogs(args)); } catch (err) { return errorResult(err); } });
|
|
533
|
+
|
|
534
|
+
registerTool('motorical_get_message_by_smtp_id', {
|
|
535
|
+
description:
|
|
536
|
+
'Look up one message by its SMTP Message-ID header (GET /api/public/v1/messages?smtpMessageId=). '
|
|
537
|
+
+ 'This is a lookup, not a listing: an exact id is required and at most one message is returned. '
|
|
538
|
+
+ 'Use motorical_get_message when you have the internal send UUID instead.',
|
|
539
|
+
inputSchema: {
|
|
540
|
+
smtpMessageId: z.string().describe('The exact SMTP Message-ID, angle brackets included'),
|
|
541
|
+
motorBlockId: blockSelector,
|
|
542
|
+
includePII: z.boolean().optional().describe('Unmask the recipient address. Requires the logs.pii scope, which is never granted to OAuth tokens — the API returns 403.')
|
|
543
|
+
}
|
|
544
|
+
}, async (args) => { try { return jsonResult(await client.getMessageBySmtpId(args)); } catch (err) { return errorResult(err); } });
|
|
545
|
+
|
|
546
|
+
registerTool('motorical_get_config', {
|
|
547
|
+
description: "Configuration of one Motor Block — its sending domain, limits and delivery settings (GET /api/public/v1/motor-blocks/{id}/config).",
|
|
548
|
+
inputSchema: { motorBlockId: blockSelector }
|
|
549
|
+
}, async (args) => { try { return jsonResult(await client.getConfig(args)); } catch (err) { return errorResult(err); } });
|
|
550
|
+
|
|
551
|
+
registerTool('motorical_get_domain_health', {
|
|
552
|
+
description: "DNS and email-authentication health for the Motor Block's sending domain — SPF, DKIM, DMARC, MX (GET /api/public/v1/motor-blocks/{id}/domain-health).",
|
|
553
|
+
inputSchema: {
|
|
554
|
+
motorBlockId: blockSelector,
|
|
555
|
+
refresh: z.boolean().optional().describe('Re-run the live DNS checks now instead of returning the last cached result')
|
|
556
|
+
}
|
|
557
|
+
}, async (args) => { try { return jsonResult(await client.getDomainHealth(args)); } catch (err) { return errorResult(err); } });
|
|
558
|
+
|
|
407
559
|
server.registerResource(
|
|
408
560
|
'motorical-llms',
|
|
409
561
|
'motorical://docs/llms.txt',
|