@motorical/mcp 1.2.0 → 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 +2 -2
- package/src/client.js +234 -28
- package/src/server.js +215 -25
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@motorical/mcp",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "MCP server for Motorical transactional email API
|
|
3
|
+
"version": "1.3.0",
|
|
4
|
+
"description": "MCP server for Motorical transactional email API — dry-run/send, mint public tokens, list Motor Blocks, inspect delivery events",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"motorical-mcp": "./src/index.js"
|
package/src/client.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
* Auth rules match docs.motorical.com (mk_live_ → /v1/send; ak_live_ → mint bearer).
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
6
7
|
import {
|
|
7
8
|
loadCredentials, saveCredentials, isExpired, discover, refreshTokens,
|
|
8
9
|
} from './oauth.js';
|
|
@@ -10,6 +11,18 @@ import {
|
|
|
10
11
|
const DEFAULT_API_BASE = 'https://api.motorical.com';
|
|
11
12
|
const DEFAULT_DOCS_BASE = 'https://docs.motorical.com';
|
|
12
13
|
|
|
14
|
+
/**
|
|
15
|
+
* Per-call forced-auth context for withAuth()/request(). This is a module-
|
|
16
|
+
* level AsyncLocalStorage, not an instance field: a plain `this._forcedAuth`
|
|
17
|
+
* field set-and-restored around an await would be shared mutable state on
|
|
18
|
+
* the client instance, so two overlapping withAuth() calls on the same
|
|
19
|
+
* client (e.g. two concurrent tool calls sharing one MotoricalClient) could
|
|
20
|
+
* stomp each other's headers depending on scheduling. ALS binds the
|
|
21
|
+
* override to the async call chain that set it, so concurrent chains never
|
|
22
|
+
* observe each other's context regardless of interleaving.
|
|
23
|
+
*/
|
|
24
|
+
const forcedAuthStorage = new AsyncLocalStorage();
|
|
25
|
+
|
|
13
26
|
export function loadConfig(env = process.env) {
|
|
14
27
|
return {
|
|
15
28
|
apiBaseUrl: (env.MOTORICAL_API_BASE_URL || DEFAULT_API_BASE).replace(/\/$/, ''),
|
|
@@ -86,9 +99,22 @@ export class MotoricalClient {
|
|
|
86
99
|
return this.config.mkApiKey;
|
|
87
100
|
}
|
|
88
101
|
|
|
89
|
-
|
|
102
|
+
/** Runs `fn` with these headers forced onto every request it makes. */
|
|
103
|
+
async withAuth(headers, fn) {
|
|
104
|
+
return forcedAuthStorage.run(headers, fn);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async request(method, path, opts = {}) {
|
|
108
|
+
const { headers = {}, body } = opts;
|
|
109
|
+
let { apiKey, bearer } = opts;
|
|
90
110
|
const url = `${this.config.apiBaseUrl}${path.startsWith('/') ? path : `/${path}`}`;
|
|
91
111
|
const h = { Accept: 'application/json', ...headers };
|
|
112
|
+
const forcedAuth = forcedAuthStorage.getStore();
|
|
113
|
+
if (forcedAuth) {
|
|
114
|
+
Object.assign(h, forcedAuth);
|
|
115
|
+
apiKey = undefined;
|
|
116
|
+
bearer = undefined;
|
|
117
|
+
}
|
|
92
118
|
if (apiKey) h.Authorization = `ApiKey ${apiKey}`;
|
|
93
119
|
if (bearer) h.Authorization = `Bearer ${bearer}`;
|
|
94
120
|
if (body !== undefined) h['Content-Type'] = 'application/json';
|
|
@@ -166,6 +192,144 @@ export class MotoricalClient {
|
|
|
166
192
|
return `${path}${sep}motorBlockId=${encodeURIComponent(id)}`;
|
|
167
193
|
}
|
|
168
194
|
|
|
195
|
+
/**
|
|
196
|
+
* The account-scoped counterpart to _scoped, for operations acting on the
|
|
197
|
+
* ACCOUNT rather than on one Motor Block (domain management, listing the
|
|
198
|
+
* blocks themselves). The backend mounts those routes with
|
|
199
|
+
* { accountScoped: true } and reads only the token's user, so a block is
|
|
200
|
+
* passed through when the caller has one and omitted when not — never
|
|
201
|
+
* demanded, and never a reason to throw.
|
|
202
|
+
*/
|
|
203
|
+
_accountPath(path, motorBlockId) {
|
|
204
|
+
if (!this.hasOAuthSession()) return path;
|
|
205
|
+
const id = motorBlockId || this.config.motorBlockId;
|
|
206
|
+
if (!id) return path;
|
|
207
|
+
const sep = path.includes('?') ? '&' : '?';
|
|
208
|
+
return `${path}${sep}motorBlockId=${encodeURIComponent(id)}`;
|
|
209
|
+
}
|
|
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
|
+
|
|
169
333
|
async getBearer({ motorBlockId, forceRefresh = false } = {}) {
|
|
170
334
|
// An OAuth grant supersedes minted public tokens entirely — no ak_live_ key
|
|
171
335
|
// and no dashboard JWT needed.
|
|
@@ -182,7 +346,7 @@ export class MotoricalClient {
|
|
|
182
346
|
|
|
183
347
|
async listMotorBlocks({ motorBlockId } = {}) {
|
|
184
348
|
const bearer = await this.getBearer({ motorBlockId });
|
|
185
|
-
return this.request('GET', this.
|
|
349
|
+
return this.request('GET', this._accountPath('/api/public/v1/motor-blocks', motorBlockId), { bearer });
|
|
186
350
|
}
|
|
187
351
|
|
|
188
352
|
async sendEmail(payload) {
|
|
@@ -197,6 +361,7 @@ export class MotoricalClient {
|
|
|
197
361
|
confirmRealSend = false,
|
|
198
362
|
idempotencyKey,
|
|
199
363
|
headers: customHeaders,
|
|
364
|
+
motorBlockId: _motorBlockId, // never in the body — see sendPath below
|
|
200
365
|
...rest
|
|
201
366
|
} = payload;
|
|
202
367
|
|
|
@@ -310,44 +475,85 @@ export class MotoricalClient {
|
|
|
310
475
|
});
|
|
311
476
|
}
|
|
312
477
|
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
478
|
+
// These four target the public API the same way listMotorBlocks/getMessage
|
|
479
|
+
// do — never branching on oauthAccessToken() to pick a URL. That branch was
|
|
480
|
+
// the bug: it's null for a delegated call by design (no stored session,
|
|
481
|
+
// auth travels per-call instead), so every one of these calls fell through
|
|
482
|
+
// to /api/domains, which rejects a Delegation header outright.
|
|
483
|
+
//
|
|
484
|
+
// One narrow exception, preserved on purpose: a dashboard-JWT-only caller
|
|
485
|
+
// (no OAuth session, MOTORICAL_JWT set directly) with NO motor block
|
|
486
|
+
// configured yet — the state a brand-new customer is in before their first
|
|
487
|
+
// Motor Block exists, but domains are account-wide and this account may
|
|
488
|
+
// already need one added. mintPublicToken() hard-requires a motorBlockId
|
|
489
|
+
// it doesn't have; /api/domains doesn't need one at all. A delegated call
|
|
490
|
+
// never hits this branch — resolveBlock() in delegatedClient.js always
|
|
491
|
+
// supplies a real motorBlockId before any of these run.
|
|
492
|
+
hasNoBlockToScopeAPublicToken(motorBlockId) {
|
|
493
|
+
// _delegated is set by delegatedClient.js's callView. A delegated call is
|
|
494
|
+
// never the legacy dashboard-JWT caller this fallback exists for: its
|
|
495
|
+
// dashboardJwt is a placeholder, not a credential, so taking this branch
|
|
496
|
+
// means authenticating the dashboard route with the string
|
|
497
|
+
// 'mcp-delegated' — a guaranteed 401. This used to be unreachable because
|
|
498
|
+
// resolveBlock() always supplied a block; account-scoped tools now supply
|
|
499
|
+
// none, so the guard has to be explicit. Found live 2026-09-02.
|
|
500
|
+
if (this._delegated) return false;
|
|
501
|
+
return !(motorBlockId || this.config.motorBlockId) && !this.hasOAuthSession();
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
async domainList({ motorBlockId } = {}) {
|
|
505
|
+
if (this.hasNoBlockToScopeAPublicToken(motorBlockId) && this.config.dashboardJwt) {
|
|
506
|
+
return this.request('GET', '/api/domains', { bearer: this.requireDashboardJwt() });
|
|
323
507
|
}
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
});
|
|
508
|
+
const bearer = await this.getBearer({ motorBlockId });
|
|
509
|
+
return this.request('GET', this._accountPath('/api/public/v1/domains', motorBlockId), { bearer });
|
|
327
510
|
}
|
|
328
511
|
|
|
329
|
-
async domainAdd({ domain, verificationMethod = 'dns' } = {}) {
|
|
512
|
+
async domainAdd({ domain, verificationMethod = 'dns', motorBlockId } = {}) {
|
|
330
513
|
if (!domain) throw new Error('domain is required');
|
|
331
|
-
|
|
332
|
-
|
|
514
|
+
if (this.hasNoBlockToScopeAPublicToken(motorBlockId) && this.config.dashboardJwt) {
|
|
515
|
+
return this.request('POST', '/api/domains', {
|
|
516
|
+
bearer: this.requireDashboardJwt(),
|
|
517
|
+
body: { domain, verificationMethod }
|
|
518
|
+
});
|
|
519
|
+
}
|
|
520
|
+
const bearer = await this.getBearer({ motorBlockId });
|
|
521
|
+
return this.request('POST', this._accountPath('/api/public/v1/domains', motorBlockId), {
|
|
522
|
+
bearer,
|
|
333
523
|
body: { domain, verificationMethod }
|
|
334
524
|
});
|
|
335
525
|
}
|
|
336
526
|
|
|
337
|
-
async domainVerify({ domainId, method = 'dns' } = {}) {
|
|
527
|
+
async domainVerify({ domainId, method = 'dns', motorBlockId } = {}) {
|
|
338
528
|
if (!domainId) throw new Error('domainId is required');
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
529
|
+
if (this.hasNoBlockToScopeAPublicToken(motorBlockId) && this.config.dashboardJwt) {
|
|
530
|
+
return this.request('POST', `/api/domains/${encodeURIComponent(domainId)}/verify`, {
|
|
531
|
+
bearer: this.requireDashboardJwt(),
|
|
532
|
+
body: { method }
|
|
533
|
+
});
|
|
534
|
+
}
|
|
535
|
+
const bearer = await this.getBearer({ motorBlockId });
|
|
536
|
+
return this.request(
|
|
537
|
+
'POST',
|
|
538
|
+
this._accountPath(`/api/public/v1/domains/${encodeURIComponent(domainId)}/verify`, motorBlockId),
|
|
539
|
+
{ bearer, body: { method } }
|
|
540
|
+
);
|
|
343
541
|
}
|
|
344
542
|
|
|
345
|
-
async domainCheckDns({ domainId, recordType } = {}) {
|
|
543
|
+
async domainCheckDns({ domainId, recordType, motorBlockId } = {}) {
|
|
346
544
|
if (!domainId) throw new Error('domainId is required');
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
545
|
+
if (this.hasNoBlockToScopeAPublicToken(motorBlockId) && this.config.dashboardJwt) {
|
|
546
|
+
return this.request('POST', `/api/domains/${encodeURIComponent(domainId)}/check-dns`, {
|
|
547
|
+
bearer: this.requireDashboardJwt(),
|
|
548
|
+
body: recordType ? { recordType } : {}
|
|
549
|
+
});
|
|
550
|
+
}
|
|
551
|
+
const bearer = await this.getBearer({ motorBlockId });
|
|
552
|
+
return this.request(
|
|
553
|
+
'POST',
|
|
554
|
+
this._accountPath(`/api/public/v1/domains/${encodeURIComponent(domainId)}/check-dns`, motorBlockId),
|
|
555
|
+
{ bearer, body: recordType ? { recordType } : {} }
|
|
556
|
+
);
|
|
351
557
|
}
|
|
352
558
|
|
|
353
559
|
async sandboxAllowlistRequest({ email } = {}) {
|
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 {
|
|
@@ -34,7 +37,19 @@ export function createMotoricalMcpServer(options = {}) {
|
|
|
34
37
|
version: PACKAGE_VERSION
|
|
35
38
|
});
|
|
36
39
|
|
|
37
|
-
|
|
40
|
+
// The stdio/CLI entrypoint (index.js) calls this with no allowedTools and
|
|
41
|
+
// gets every tool, unaffected. The HTTP resource server (http.js) passes
|
|
42
|
+
// the connected server's own tool list: without this, tools/list would
|
|
43
|
+
// advertise every tool on every path — e.g. motorical_send_email on the
|
|
44
|
+
// analytics-only server — even though calling it there would be refused.
|
|
45
|
+
// The advertisement must match what's actually callable.
|
|
46
|
+
const allowedTools = options.allowedTools || null;
|
|
47
|
+
function registerTool(name, config, cb) {
|
|
48
|
+
if (allowedTools && !allowedTools.includes(name)) return;
|
|
49
|
+
server.registerTool(name, config, cb);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
registerTool(
|
|
38
53
|
'motorical_get_send_status',
|
|
39
54
|
{
|
|
40
55
|
description:
|
|
@@ -50,7 +65,7 @@ export function createMotoricalMcpServer(options = {}) {
|
|
|
50
65
|
}
|
|
51
66
|
);
|
|
52
67
|
|
|
53
|
-
|
|
68
|
+
registerTool(
|
|
54
69
|
'motorical_mint_public_token',
|
|
55
70
|
{
|
|
56
71
|
description:
|
|
@@ -72,13 +87,13 @@ export function createMotoricalMcpServer(options = {}) {
|
|
|
72
87
|
}
|
|
73
88
|
);
|
|
74
89
|
|
|
75
|
-
|
|
90
|
+
registerTool(
|
|
76
91
|
'motorical_list_motor_blocks',
|
|
77
92
|
{
|
|
78
93
|
description:
|
|
79
94
|
'List Motor Blocks (isolated sending streams) visible to a Public API bearer token (auto-mints with ak_live_ if needed).',
|
|
80
95
|
inputSchema: {
|
|
81
|
-
motorBlockId: z.string().uuid().optional().describe('
|
|
96
|
+
motorBlockId: z.string().uuid().optional().describe('Optional. Listing is account-wide; a block is only used when minting a legacy public token on the non-OAuth path.')
|
|
82
97
|
}
|
|
83
98
|
},
|
|
84
99
|
async (args) => {
|
|
@@ -90,7 +105,7 @@ export function createMotoricalMcpServer(options = {}) {
|
|
|
90
105
|
}
|
|
91
106
|
);
|
|
92
107
|
|
|
93
|
-
|
|
108
|
+
registerTool(
|
|
94
109
|
'motorical_send_email',
|
|
95
110
|
{
|
|
96
111
|
description:
|
|
@@ -115,7 +130,12 @@ export function createMotoricalMcpServer(options = {}) {
|
|
|
115
130
|
.boolean()
|
|
116
131
|
.optional()
|
|
117
132
|
.describe('Required true when dryRun is false'),
|
|
118
|
-
idempotencyKey: z.string().optional()
|
|
133
|
+
idempotencyKey: z.string().optional(),
|
|
134
|
+
motorBlockId: z
|
|
135
|
+
.string()
|
|
136
|
+
.uuid()
|
|
137
|
+
.optional()
|
|
138
|
+
.describe('Required when the authorization covers more than one Motor Block')
|
|
119
139
|
}
|
|
120
140
|
},
|
|
121
141
|
async (args) => {
|
|
@@ -127,7 +147,7 @@ export function createMotoricalMcpServer(options = {}) {
|
|
|
127
147
|
}
|
|
128
148
|
);
|
|
129
149
|
|
|
130
|
-
|
|
150
|
+
registerTool(
|
|
131
151
|
'motorical_get_message',
|
|
132
152
|
{
|
|
133
153
|
description: 'Get a message by send UUID (GET /api/public/v1/messages/{id}). Auto-mints bearer if needed.',
|
|
@@ -146,7 +166,7 @@ export function createMotoricalMcpServer(options = {}) {
|
|
|
146
166
|
}
|
|
147
167
|
);
|
|
148
168
|
|
|
149
|
-
|
|
169
|
+
registerTool(
|
|
150
170
|
'motorical_get_message_events',
|
|
151
171
|
{
|
|
152
172
|
description:
|
|
@@ -166,7 +186,7 @@ export function createMotoricalMcpServer(options = {}) {
|
|
|
166
186
|
}
|
|
167
187
|
);
|
|
168
188
|
|
|
169
|
-
|
|
189
|
+
registerTool(
|
|
170
190
|
'motorical_sandbox_status',
|
|
171
191
|
{
|
|
172
192
|
description:
|
|
@@ -186,7 +206,7 @@ export function createMotoricalMcpServer(options = {}) {
|
|
|
186
206
|
}
|
|
187
207
|
);
|
|
188
208
|
|
|
189
|
-
|
|
209
|
+
registerTool(
|
|
190
210
|
'motorical_sandbox_allowlist_request',
|
|
191
211
|
{
|
|
192
212
|
description:
|
|
@@ -206,7 +226,7 @@ export function createMotoricalMcpServer(options = {}) {
|
|
|
206
226
|
}
|
|
207
227
|
);
|
|
208
228
|
|
|
209
|
-
|
|
229
|
+
registerTool(
|
|
210
230
|
'motorical_sandbox_allowlist_confirm',
|
|
211
231
|
{
|
|
212
232
|
description:
|
|
@@ -227,7 +247,7 @@ export function createMotoricalMcpServer(options = {}) {
|
|
|
227
247
|
}
|
|
228
248
|
);
|
|
229
249
|
|
|
230
|
-
|
|
250
|
+
registerTool(
|
|
231
251
|
'motorical_sandbox_provision',
|
|
232
252
|
{
|
|
233
253
|
description:
|
|
@@ -247,7 +267,7 @@ export function createMotoricalMcpServer(options = {}) {
|
|
|
247
267
|
}
|
|
248
268
|
);
|
|
249
269
|
|
|
250
|
-
|
|
270
|
+
registerTool(
|
|
251
271
|
'motorical_sandbox_convert',
|
|
252
272
|
{
|
|
253
273
|
description:
|
|
@@ -267,7 +287,7 @@ export function createMotoricalMcpServer(options = {}) {
|
|
|
267
287
|
}
|
|
268
288
|
);
|
|
269
289
|
|
|
270
|
-
|
|
290
|
+
registerTool(
|
|
271
291
|
'motorical_domain_add',
|
|
272
292
|
{
|
|
273
293
|
description:
|
|
@@ -277,7 +297,12 @@ export function createMotoricalMcpServer(options = {}) {
|
|
|
277
297
|
'this account before asking the user to resolve the conflict. Requires MOTORICAL_JWT.',
|
|
278
298
|
inputSchema: {
|
|
279
299
|
domain: z.string().min(3),
|
|
280
|
-
verificationMethod: z.enum(['dns', 'email']).optional()
|
|
300
|
+
verificationMethod: z.enum(['dns', 'email']).optional(),
|
|
301
|
+
motorBlockId: z
|
|
302
|
+
.string()
|
|
303
|
+
.uuid()
|
|
304
|
+
.optional()
|
|
305
|
+
.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.')
|
|
281
306
|
}
|
|
282
307
|
},
|
|
283
308
|
async (args) => {
|
|
@@ -289,25 +314,31 @@ export function createMotoricalMcpServer(options = {}) {
|
|
|
289
314
|
}
|
|
290
315
|
);
|
|
291
316
|
|
|
292
|
-
|
|
317
|
+
registerTool(
|
|
293
318
|
'motorical_domain_list',
|
|
294
319
|
{
|
|
295
320
|
description:
|
|
296
321
|
'List domains already on this account (GET /api/domains) — id, domain, verified, DNS auth flags. ' +
|
|
297
322
|
'Call this before motorical_domain_add on a 409 conflict to self-diagnose whether the domain is already ' +
|
|
298
323
|
'yours (proceed with the existing id) or genuinely owned by someone else (stop, do not guess). Requires MOTORICAL_JWT.',
|
|
299
|
-
inputSchema: {
|
|
324
|
+
inputSchema: {
|
|
325
|
+
motorBlockId: z
|
|
326
|
+
.string()
|
|
327
|
+
.uuid()
|
|
328
|
+
.optional()
|
|
329
|
+
.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.')
|
|
330
|
+
}
|
|
300
331
|
},
|
|
301
|
-
async () => {
|
|
332
|
+
async (args) => {
|
|
302
333
|
try {
|
|
303
|
-
return jsonResult(await client.domainList());
|
|
334
|
+
return jsonResult(await client.domainList(args));
|
|
304
335
|
} catch (err) {
|
|
305
336
|
return errorResult(err);
|
|
306
337
|
}
|
|
307
338
|
}
|
|
308
339
|
);
|
|
309
340
|
|
|
310
|
-
|
|
341
|
+
registerTool(
|
|
311
342
|
'motorical_domain_verify',
|
|
312
343
|
{
|
|
313
344
|
description:
|
|
@@ -315,7 +346,12 @@ export function createMotoricalMcpServer(options = {}) {
|
|
|
315
346
|
'Safe to re-call after ownership is done — returns sendReady. Requires MOTORICAL_JWT.',
|
|
316
347
|
inputSchema: {
|
|
317
348
|
domainId: z.string().uuid(),
|
|
318
|
-
method: z.enum(['dns', 'email']).optional()
|
|
349
|
+
method: z.enum(['dns', 'email']).optional(),
|
|
350
|
+
motorBlockId: z
|
|
351
|
+
.string()
|
|
352
|
+
.uuid()
|
|
353
|
+
.optional()
|
|
354
|
+
.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.')
|
|
319
355
|
}
|
|
320
356
|
},
|
|
321
357
|
async (args) => {
|
|
@@ -327,7 +363,7 @@ export function createMotoricalMcpServer(options = {}) {
|
|
|
327
363
|
}
|
|
328
364
|
);
|
|
329
365
|
|
|
330
|
-
|
|
366
|
+
registerTool(
|
|
331
367
|
'motorical_domain_check_dns',
|
|
332
368
|
{
|
|
333
369
|
description:
|
|
@@ -335,7 +371,12 @@ export function createMotoricalMcpServer(options = {}) {
|
|
|
335
371
|
'Required before /v1/send when ownership is verified but send returns DOMAIN_DNS_INCOMPLETE. Requires MOTORICAL_JWT.',
|
|
336
372
|
inputSchema: {
|
|
337
373
|
domainId: z.string().uuid(),
|
|
338
|
-
recordType: z.enum(['dkim', 'spf', 'dmarc', 'mx']).optional()
|
|
374
|
+
recordType: z.enum(['dkim', 'spf', 'dmarc', 'mx']).optional(),
|
|
375
|
+
motorBlockId: z
|
|
376
|
+
.string()
|
|
377
|
+
.uuid()
|
|
378
|
+
.optional()
|
|
379
|
+
.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.')
|
|
339
380
|
}
|
|
340
381
|
},
|
|
341
382
|
async (args) => {
|
|
@@ -347,7 +388,7 @@ export function createMotoricalMcpServer(options = {}) {
|
|
|
347
388
|
}
|
|
348
389
|
);
|
|
349
390
|
|
|
350
|
-
|
|
391
|
+
registerTool(
|
|
351
392
|
'motorical_web_handoff',
|
|
352
393
|
{
|
|
353
394
|
description:
|
|
@@ -366,6 +407,155 @@ export function createMotoricalMcpServer(options = {}) {
|
|
|
366
407
|
}
|
|
367
408
|
);
|
|
368
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
|
+
|
|
369
559
|
server.registerResource(
|
|
370
560
|
'motorical-llms',
|
|
371
561
|
'motorical://docs/llms.txt',
|