@motorical/mcp 1.1.3 → 1.2.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/README.md +33 -0
- package/package.json +2 -2
- package/src/client.js +211 -25
- package/src/index.js +27 -2
- package/src/login.js +105 -0
- package/src/oauth.js +235 -0
- package/src/server.js +62 -24
package/README.md
CHANGED
|
@@ -25,6 +25,39 @@ A **Motorical SMTP Motor Block** is an isolated sending stream (similar to a per
|
|
|
25
25
|
|
|
26
26
|
Safety: real sends require `dryRun: false` **and** `confirmRealSend: true`. Sandbox outbound is allowlist-locked until convert. Optional `fromName` sets the inbox display name (same as HTTP `/v1/send` / CLI `--from-name`); do not put `From` in custom headers.
|
|
27
27
|
|
|
28
|
+
## Authorization (recommended)
|
|
29
|
+
|
|
30
|
+
Sign in once; no keys to paste, copy, or rotate by hand:
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
npx @motorical/mcp login
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
This opens your browser, you approve the scopes on Motorical's consent screen,
|
|
37
|
+
and the grant is stored at `~/.motorical/mcp-credentials.json` (owner-only,
|
|
38
|
+
`0600`). Access tokens last an hour and refresh silently.
|
|
39
|
+
|
|
40
|
+
- `motorical-mcp status` — show the current connection
|
|
41
|
+
|
|
42
|
+
An authorization covers **every Motor Block you own**, so block-scoped calls
|
|
43
|
+
must say which one they mean. Set `MOTORICAL_MOTOR_BLOCK_ID` (or pass
|
|
44
|
+
`motorBlockId` per call); the tools fail with a clear message rather than a bare
|
|
45
|
+
API error if it is missing.
|
|
46
|
+
- `motorical-mcp logout` — remove the local credentials
|
|
47
|
+
|
|
48
|
+
Logging out only deletes the local file. To cut the agent's access off at the
|
|
49
|
+
server, revoke the connection in **Settings → API Access → Connected AI
|
|
50
|
+
Agents**; that takes effect immediately, on every device, and does not sign you
|
|
51
|
+
out of your own dashboard.
|
|
52
|
+
|
|
53
|
+
Under the hood this is OAuth 2.1: PKCE `S256`, a loopback redirect, RFC 8707
|
|
54
|
+
audience-bound tokens, and RFC 9207 issuer checking on the callback. The client
|
|
55
|
+
is public, so refresh tokens rotate on every use.
|
|
56
|
+
|
|
57
|
+
The environment variables below still work and are the right choice for CI or a
|
|
58
|
+
headless server, where no browser is available. When an OAuth session is present
|
|
59
|
+
it takes precedence over them.
|
|
60
|
+
|
|
28
61
|
## Environment
|
|
29
62
|
|
|
30
63
|
| Variable | Required | Description |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@motorical/mcp",
|
|
3
|
-
"version": "1.1
|
|
3
|
+
"version": "1.2.1",
|
|
4
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": {
|
|
@@ -42,7 +42,7 @@
|
|
|
42
42
|
},
|
|
43
43
|
"homepage": "https://docs.motorical.com/ai-mcp",
|
|
44
44
|
"bugs": {
|
|
45
|
-
"url": "https://github.com/motorical-smtp/motorical-
|
|
45
|
+
"url": "https://github.com/motorical-smtp/motorical-backend/issues"
|
|
46
46
|
},
|
|
47
47
|
"repository": {
|
|
48
48
|
"type": "git",
|
package/src/client.js
CHANGED
|
@@ -3,9 +3,26 @@
|
|
|
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';
|
|
7
|
+
import {
|
|
8
|
+
loadCredentials, saveCredentials, isExpired, discover, refreshTokens,
|
|
9
|
+
} from './oauth.js';
|
|
10
|
+
|
|
6
11
|
const DEFAULT_API_BASE = 'https://api.motorical.com';
|
|
7
12
|
const DEFAULT_DOCS_BASE = 'https://docs.motorical.com';
|
|
8
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
|
+
|
|
9
26
|
export function loadConfig(env = process.env) {
|
|
10
27
|
return {
|
|
11
28
|
apiBaseUrl: (env.MOTORICAL_API_BASE_URL || DEFAULT_API_BASE).replace(/\/$/, ''),
|
|
@@ -15,7 +32,9 @@ export function loadConfig(env = process.env) {
|
|
|
15
32
|
bearerToken: env.MOTORICAL_BEARER_TOKEN || '',
|
|
16
33
|
dashboardJwt: env.MOTORICAL_JWT || '',
|
|
17
34
|
motorBlockId: env.MOTORICAL_MOTOR_BLOCK_ID || '',
|
|
18
|
-
defaultFrom: env.MOTORICAL_DEFAULT_FROM || ''
|
|
35
|
+
defaultFrom: env.MOTORICAL_DEFAULT_FROM || '',
|
|
36
|
+
// A grant from `motorical-mcp login`, when one exists.
|
|
37
|
+
oauthCredentials: loadCredentials()
|
|
19
38
|
};
|
|
20
39
|
}
|
|
21
40
|
|
|
@@ -26,19 +45,76 @@ export class MotoricalClient {
|
|
|
26
45
|
this._cachedBearer = config.bearerToken || null;
|
|
27
46
|
/** @type {string|null} */
|
|
28
47
|
this._cachedMkKey = null;
|
|
48
|
+
/**
|
|
49
|
+
* An OAuth grant from `motorical-mcp login`. When present it is preferred
|
|
50
|
+
* over pasted keys: it is scoped, revocable from the dashboard, and expires.
|
|
51
|
+
*/
|
|
52
|
+
// Read ONLY from config. An implicit loadCredentials() here would make the
|
|
53
|
+
// client behave differently on a machine that happens to have logged in —
|
|
54
|
+
// including inside tests. loadConfig() is the one place the disk is read.
|
|
55
|
+
this._oauth = config.oauthCredentials || null;
|
|
56
|
+
this._oauthMeta = null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
hasOAuthSession() {
|
|
60
|
+
return !!(this._oauth && this._oauth.accessToken);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Returns a live OAuth access token, refreshing (and rotating) if needed. */
|
|
64
|
+
async oauthAccessToken({ fetchImpl } = {}) {
|
|
65
|
+
if (!this.hasOAuthSession()) return null;
|
|
66
|
+
if (!isExpired(this._oauth)) return this._oauth.accessToken;
|
|
67
|
+
|
|
68
|
+
if (!this._oauth.refreshToken) {
|
|
69
|
+
throw new Error('Motorical session expired and has no refresh token. Run: motorical-mcp login');
|
|
70
|
+
}
|
|
71
|
+
if (!this._oauthMeta) {
|
|
72
|
+
this._oauthMeta = await discover(this._oauth.issuer, fetchImpl || fetch);
|
|
73
|
+
}
|
|
74
|
+
let tokens;
|
|
75
|
+
try {
|
|
76
|
+
tokens = await refreshTokens(this._oauthMeta, { refreshToken: this._oauth.refreshToken }, fetchImpl || fetch);
|
|
77
|
+
} catch (err) {
|
|
78
|
+
throw new Error(`Motorical session could not be refreshed (${err.message}). Run: motorical-mcp login`);
|
|
79
|
+
}
|
|
80
|
+
this._oauth = {
|
|
81
|
+
...this._oauth,
|
|
82
|
+
accessToken: tokens.access_token,
|
|
83
|
+
// Rotation is mandatory for public clients: the old refresh token is dead.
|
|
84
|
+
refreshToken: tokens.refresh_token || this._oauth.refreshToken,
|
|
85
|
+
scope: tokens.scope || this._oauth.scope,
|
|
86
|
+
expiresAt: Date.now() + (Number(tokens.expires_in || 3600) * 1000),
|
|
87
|
+
};
|
|
88
|
+
try { saveCredentials(this._oauth); } catch { /* keep working in-memory */ }
|
|
89
|
+
return this._oauth.accessToken;
|
|
29
90
|
}
|
|
30
91
|
|
|
31
92
|
requireMk() {
|
|
32
93
|
if (this._cachedMkKey) return this._cachedMkKey;
|
|
33
94
|
if (!this.config.mkApiKey) {
|
|
34
|
-
throw new Error(
|
|
95
|
+
throw new Error(
|
|
96
|
+
'No credentials for sending. Run `motorical-mcp login`, or set MOTORICAL_MK_API_KEY (mk_live_...).'
|
|
97
|
+
);
|
|
35
98
|
}
|
|
36
99
|
return this.config.mkApiKey;
|
|
37
100
|
}
|
|
38
101
|
|
|
39
|
-
|
|
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;
|
|
40
110
|
const url = `${this.config.apiBaseUrl}${path.startsWith('/') ? path : `/${path}`}`;
|
|
41
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
|
+
}
|
|
42
118
|
if (apiKey) h.Authorization = `ApiKey ${apiKey}`;
|
|
43
119
|
if (bearer) h.Authorization = `Bearer ${bearer}`;
|
|
44
120
|
if (body !== undefined) h['Content-Type'] = 'application/json';
|
|
@@ -96,7 +172,48 @@ export class MotoricalClient {
|
|
|
96
172
|
return data;
|
|
97
173
|
}
|
|
98
174
|
|
|
175
|
+
/**
|
|
176
|
+
* An OAuth grant covers every Motor Block the user owned at consent time, so
|
|
177
|
+
* a block-scoped request must say which one it means. (A minted public token
|
|
178
|
+
* carried a single block inside the token, so this never came up before.)
|
|
179
|
+
* Fails here with an actionable message rather than letting the API answer
|
|
180
|
+
* with a bare 400.
|
|
181
|
+
*/
|
|
182
|
+
_scoped(path, motorBlockId) {
|
|
183
|
+
if (!this.hasOAuthSession()) return path;
|
|
184
|
+
const id = motorBlockId || this.config.motorBlockId;
|
|
185
|
+
if (!id) {
|
|
186
|
+
throw new Error(
|
|
187
|
+
'motorBlockId is required: your Motorical authorization covers multiple Motor Blocks. '
|
|
188
|
+
+ 'Pass motorBlockId, or set MOTORICAL_MOTOR_BLOCK_ID.'
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
const sep = path.includes('?') ? '&' : '?';
|
|
192
|
+
return `${path}${sep}motorBlockId=${encodeURIComponent(id)}`;
|
|
193
|
+
}
|
|
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
|
+
|
|
99
211
|
async getBearer({ motorBlockId, forceRefresh = false } = {}) {
|
|
212
|
+
// An OAuth grant supersedes minted public tokens entirely — no ak_live_ key
|
|
213
|
+
// and no dashboard JWT needed.
|
|
214
|
+
const oauthToken = await this.oauthAccessToken();
|
|
215
|
+
if (oauthToken) return oauthToken;
|
|
216
|
+
|
|
100
217
|
if (this._cachedBearer && !forceRefresh) return this._cachedBearer;
|
|
101
218
|
const minted = await this.mintPublicToken({ motorBlockId });
|
|
102
219
|
const token = minted?.data?.token || minted?.token || minted?.access_token;
|
|
@@ -107,7 +224,7 @@ export class MotoricalClient {
|
|
|
107
224
|
|
|
108
225
|
async listMotorBlocks({ motorBlockId } = {}) {
|
|
109
226
|
const bearer = await this.getBearer({ motorBlockId });
|
|
110
|
-
return this.request('GET', '/api/public/v1/motor-blocks', { bearer });
|
|
227
|
+
return this.request('GET', this._accountPath('/api/public/v1/motor-blocks', motorBlockId), { bearer });
|
|
111
228
|
}
|
|
112
229
|
|
|
113
230
|
async sendEmail(payload) {
|
|
@@ -122,6 +239,7 @@ export class MotoricalClient {
|
|
|
122
239
|
confirmRealSend = false,
|
|
123
240
|
idempotencyKey,
|
|
124
241
|
headers: customHeaders,
|
|
242
|
+
motorBlockId: _motorBlockId, // never in the body — see sendPath below
|
|
125
243
|
...rest
|
|
126
244
|
} = payload;
|
|
127
245
|
|
|
@@ -140,8 +258,25 @@ export class MotoricalClient {
|
|
|
140
258
|
const headers = {};
|
|
141
259
|
if (idempotencyKey) headers['Idempotency-Key'] = idempotencyKey;
|
|
142
260
|
|
|
143
|
-
|
|
144
|
-
|
|
261
|
+
const oauthToken = await this.oauthAccessToken();
|
|
262
|
+
if (oauthToken && !this.config.motorBlockId && !payload.motorBlockId) {
|
|
263
|
+
throw new Error(
|
|
264
|
+
'motorBlockId is required to send with an OAuth authorization: it covers every Motor Block '
|
|
265
|
+
+ 'you own. Set MOTORICAL_MOTOR_BLOCK_ID, or pass motorBlockId.'
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
const auth = oauthToken ? { bearer: oauthToken } : { apiKey: this.requireMk() };
|
|
269
|
+
|
|
270
|
+
// The block goes in the query string, not the body: /v1/send validates its
|
|
271
|
+
// body strictly and rejects unknown keys, and an api-key client's block
|
|
272
|
+
// comes from the key itself — a body field that disagreed would be a
|
|
273
|
+
// silent footgun.
|
|
274
|
+
const sendPath = oauthToken
|
|
275
|
+
? `/v1/send?motorBlockId=${encodeURIComponent(payload.motorBlockId || this.config.motorBlockId)}`
|
|
276
|
+
: '/v1/send';
|
|
277
|
+
|
|
278
|
+
return this.request('POST', sendPath, {
|
|
279
|
+
...auth,
|
|
145
280
|
headers,
|
|
146
281
|
body: {
|
|
147
282
|
from: fromAddr,
|
|
@@ -161,14 +296,14 @@ export class MotoricalClient {
|
|
|
161
296
|
if (!messageId) throw new Error('messageId is required');
|
|
162
297
|
const bearer = await this.getBearer({ motorBlockId });
|
|
163
298
|
const q = includePII ? '?includePII=true' : '';
|
|
164
|
-
return this.request('GET', `/api/public/v1/messages/${encodeURIComponent(messageId)}${q}`, { bearer });
|
|
299
|
+
return this.request('GET', this._scoped(`/api/public/v1/messages/${encodeURIComponent(messageId)}${q}`, motorBlockId), { bearer });
|
|
165
300
|
}
|
|
166
301
|
|
|
167
302
|
async getMessageEvents(messageId, { includePII = false, motorBlockId } = {}) {
|
|
168
303
|
if (!messageId) throw new Error('messageId is required');
|
|
169
304
|
const bearer = await this.getBearer({ motorBlockId });
|
|
170
305
|
const q = includePII ? '?includePII=true' : '';
|
|
171
|
-
return this.request('GET', `/api/public/v1/messages/${encodeURIComponent(messageId)}/events${q}`, { bearer });
|
|
306
|
+
return this.request('GET', this._scoped(`/api/public/v1/messages/${encodeURIComponent(messageId)}/events${q}`, motorBlockId), { bearer });
|
|
172
307
|
}
|
|
173
308
|
|
|
174
309
|
async getSendApiStatus() {
|
|
@@ -218,34 +353,85 @@ export class MotoricalClient {
|
|
|
218
353
|
});
|
|
219
354
|
}
|
|
220
355
|
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
356
|
+
// These four target the public API the same way listMotorBlocks/getMessage
|
|
357
|
+
// do — never branching on oauthAccessToken() to pick a URL. That branch was
|
|
358
|
+
// the bug: it's null for a delegated call by design (no stored session,
|
|
359
|
+
// auth travels per-call instead), so every one of these calls fell through
|
|
360
|
+
// to /api/domains, which rejects a Delegation header outright.
|
|
361
|
+
//
|
|
362
|
+
// One narrow exception, preserved on purpose: a dashboard-JWT-only caller
|
|
363
|
+
// (no OAuth session, MOTORICAL_JWT set directly) with NO motor block
|
|
364
|
+
// configured yet — the state a brand-new customer is in before their first
|
|
365
|
+
// Motor Block exists, but domains are account-wide and this account may
|
|
366
|
+
// already need one added. mintPublicToken() hard-requires a motorBlockId
|
|
367
|
+
// it doesn't have; /api/domains doesn't need one at all. A delegated call
|
|
368
|
+
// never hits this branch — resolveBlock() in delegatedClient.js always
|
|
369
|
+
// supplies a real motorBlockId before any of these run.
|
|
370
|
+
hasNoBlockToScopeAPublicToken(motorBlockId) {
|
|
371
|
+
// _delegated is set by delegatedClient.js's callView. A delegated call is
|
|
372
|
+
// never the legacy dashboard-JWT caller this fallback exists for: its
|
|
373
|
+
// dashboardJwt is a placeholder, not a credential, so taking this branch
|
|
374
|
+
// means authenticating the dashboard route with the string
|
|
375
|
+
// 'mcp-delegated' — a guaranteed 401. This used to be unreachable because
|
|
376
|
+
// resolveBlock() always supplied a block; account-scoped tools now supply
|
|
377
|
+
// none, so the guard has to be explicit. Found live 2026-09-02.
|
|
378
|
+
if (this._delegated) return false;
|
|
379
|
+
return !(motorBlockId || this.config.motorBlockId) && !this.hasOAuthSession();
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
async domainList({ motorBlockId } = {}) {
|
|
383
|
+
if (this.hasNoBlockToScopeAPublicToken(motorBlockId) && this.config.dashboardJwt) {
|
|
384
|
+
return this.request('GET', '/api/domains', { bearer: this.requireDashboardJwt() });
|
|
385
|
+
}
|
|
386
|
+
const bearer = await this.getBearer({ motorBlockId });
|
|
387
|
+
return this.request('GET', this._accountPath('/api/public/v1/domains', motorBlockId), { bearer });
|
|
225
388
|
}
|
|
226
389
|
|
|
227
|
-
async domainAdd({ domain, verificationMethod = 'dns' } = {}) {
|
|
390
|
+
async domainAdd({ domain, verificationMethod = 'dns', motorBlockId } = {}) {
|
|
228
391
|
if (!domain) throw new Error('domain is required');
|
|
229
|
-
|
|
230
|
-
|
|
392
|
+
if (this.hasNoBlockToScopeAPublicToken(motorBlockId) && this.config.dashboardJwt) {
|
|
393
|
+
return this.request('POST', '/api/domains', {
|
|
394
|
+
bearer: this.requireDashboardJwt(),
|
|
395
|
+
body: { domain, verificationMethod }
|
|
396
|
+
});
|
|
397
|
+
}
|
|
398
|
+
const bearer = await this.getBearer({ motorBlockId });
|
|
399
|
+
return this.request('POST', this._accountPath('/api/public/v1/domains', motorBlockId), {
|
|
400
|
+
bearer,
|
|
231
401
|
body: { domain, verificationMethod }
|
|
232
402
|
});
|
|
233
403
|
}
|
|
234
404
|
|
|
235
|
-
async domainVerify({ domainId, method = 'dns' } = {}) {
|
|
405
|
+
async domainVerify({ domainId, method = 'dns', motorBlockId } = {}) {
|
|
236
406
|
if (!domainId) throw new Error('domainId is required');
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
407
|
+
if (this.hasNoBlockToScopeAPublicToken(motorBlockId) && this.config.dashboardJwt) {
|
|
408
|
+
return this.request('POST', `/api/domains/${encodeURIComponent(domainId)}/verify`, {
|
|
409
|
+
bearer: this.requireDashboardJwt(),
|
|
410
|
+
body: { method }
|
|
411
|
+
});
|
|
412
|
+
}
|
|
413
|
+
const bearer = await this.getBearer({ motorBlockId });
|
|
414
|
+
return this.request(
|
|
415
|
+
'POST',
|
|
416
|
+
this._accountPath(`/api/public/v1/domains/${encodeURIComponent(domainId)}/verify`, motorBlockId),
|
|
417
|
+
{ bearer, body: { method } }
|
|
418
|
+
);
|
|
241
419
|
}
|
|
242
420
|
|
|
243
|
-
async domainCheckDns({ domainId, recordType } = {}) {
|
|
421
|
+
async domainCheckDns({ domainId, recordType, motorBlockId } = {}) {
|
|
244
422
|
if (!domainId) throw new Error('domainId is required');
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
423
|
+
if (this.hasNoBlockToScopeAPublicToken(motorBlockId) && this.config.dashboardJwt) {
|
|
424
|
+
return this.request('POST', `/api/domains/${encodeURIComponent(domainId)}/check-dns`, {
|
|
425
|
+
bearer: this.requireDashboardJwt(),
|
|
426
|
+
body: recordType ? { recordType } : {}
|
|
427
|
+
});
|
|
428
|
+
}
|
|
429
|
+
const bearer = await this.getBearer({ motorBlockId });
|
|
430
|
+
return this.request(
|
|
431
|
+
'POST',
|
|
432
|
+
this._accountPath(`/api/public/v1/domains/${encodeURIComponent(domainId)}/check-dns`, motorBlockId),
|
|
433
|
+
{ bearer, body: recordType ? { recordType } : {} }
|
|
434
|
+
);
|
|
249
435
|
}
|
|
250
436
|
|
|
251
437
|
async sandboxAllowlistRequest({ email } = {}) {
|
package/src/index.js
CHANGED
|
@@ -1,13 +1,38 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
3
|
* Motorical MCP server — stdio transport for Cursor / Claude Desktop / etc.
|
|
4
|
-
*
|
|
4
|
+
* Auth: `motorical-mcp login` (OAuth 2.1), or env credentials (see README).
|
|
5
|
+
* Log only to stderr — stdout is the MCP stream.
|
|
5
6
|
*/
|
|
6
7
|
|
|
7
8
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
8
9
|
import { createMotoricalMcpServer } from './server.js';
|
|
9
10
|
|
|
11
|
+
const USAGE = `motorical-mcp — Motorical MCP server
|
|
12
|
+
|
|
13
|
+
motorical-mcp start the MCP server on stdio (default)
|
|
14
|
+
motorical-mcp login authorize with Motorical in your browser
|
|
15
|
+
motorical-mcp logout revoke the authorization and remove local credentials
|
|
16
|
+
(--local-only to forget it without revoking)
|
|
17
|
+
motorical-mcp status show the current connection
|
|
18
|
+
`;
|
|
19
|
+
|
|
10
20
|
async function main() {
|
|
21
|
+
const cmd = process.argv[2];
|
|
22
|
+
|
|
23
|
+
if (cmd === 'login' || cmd === 'logout' || cmd === 'status') {
|
|
24
|
+
const mod = await import('./login.js');
|
|
25
|
+
if (cmd === 'login') await mod.login();
|
|
26
|
+
if (cmd === 'logout') await mod.logout({ revoke: !process.argv.includes('--local-only') });
|
|
27
|
+
if (cmd === 'status') mod.status();
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (cmd === '--help' || cmd === '-h' || cmd === 'help') {
|
|
32
|
+
console.error(USAGE);
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
|
|
11
36
|
const { server } = createMotoricalMcpServer();
|
|
12
37
|
const transport = new StdioServerTransport();
|
|
13
38
|
await server.connect(transport);
|
|
@@ -15,6 +40,6 @@ async function main() {
|
|
|
15
40
|
}
|
|
16
41
|
|
|
17
42
|
main().catch((err) => {
|
|
18
|
-
console.error('[motorical-mcp] fatal:', err);
|
|
43
|
+
console.error('[motorical-mcp] fatal:', err.message || err);
|
|
19
44
|
process.exit(1);
|
|
20
45
|
});
|
package/src/login.js
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `motorical-mcp login` — interactive OAuth 2.1 authorization.
|
|
3
|
+
* Everything it prints goes to stderr so it never corrupts an stdio MCP stream.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import crypto from 'node:crypto';
|
|
7
|
+
import { spawn } from 'node:child_process';
|
|
8
|
+
import {
|
|
9
|
+
DEFAULT_ISSUER, DEFAULT_RESOURCE,
|
|
10
|
+
discover, generatePkce, buildAuthorizeUrl, startCallbackServer,
|
|
11
|
+
exchangeCode, toStoredCredentials, saveCredentials, clearCredentials, revokeToken,
|
|
12
|
+
credentialsPath, loadCredentials,
|
|
13
|
+
} from './oauth.js';
|
|
14
|
+
|
|
15
|
+
const DEFAULT_SCOPES = ['send:transactional', 'read:analytics', 'manage:domains'];
|
|
16
|
+
|
|
17
|
+
function openBrowser(url) {
|
|
18
|
+
const cmd = process.platform === 'darwin' ? 'open'
|
|
19
|
+
: process.platform === 'win32' ? 'start' : 'xdg-open';
|
|
20
|
+
try {
|
|
21
|
+
spawn(cmd, [url], { stdio: 'ignore', detached: true, shell: process.platform === 'win32' }).unref();
|
|
22
|
+
return true;
|
|
23
|
+
} catch {
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function login({
|
|
29
|
+
issuer = process.env.MOTORICAL_ISSUER || DEFAULT_ISSUER,
|
|
30
|
+
resource = process.env.MOTORICAL_RESOURCE || DEFAULT_RESOURCE,
|
|
31
|
+
scopes = DEFAULT_SCOPES,
|
|
32
|
+
open = true,
|
|
33
|
+
log = (m) => console.error(m),
|
|
34
|
+
} = {}) {
|
|
35
|
+
const meta = await discover(issuer);
|
|
36
|
+
const { verifier, challenge } = generatePkce();
|
|
37
|
+
const state = crypto.randomBytes(16).toString('base64url');
|
|
38
|
+
|
|
39
|
+
const server = await startCallbackServer({ expectedState: state, expectedIssuer: issuer });
|
|
40
|
+
const authorizeUrl = buildAuthorizeUrl(meta, {
|
|
41
|
+
redirectUri: server.redirectUri, challenge, state, scopes, resource,
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
log('');
|
|
45
|
+
log('Authorize Motorical in your browser:');
|
|
46
|
+
log(` ${authorizeUrl}`);
|
|
47
|
+
log('');
|
|
48
|
+
if (open && !openBrowser(authorizeUrl)) {
|
|
49
|
+
log('(could not open a browser automatically — copy the link above)');
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
let code;
|
|
53
|
+
try {
|
|
54
|
+
code = await server.waitForCode;
|
|
55
|
+
} catch (err) {
|
|
56
|
+
server.close();
|
|
57
|
+
throw err;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const tokens = await exchangeCode(meta, {
|
|
61
|
+
code, verifier, redirectUri: server.redirectUri, resource,
|
|
62
|
+
});
|
|
63
|
+
const creds = toStoredCredentials(tokens, { issuer, resource });
|
|
64
|
+
const file = saveCredentials(creds);
|
|
65
|
+
|
|
66
|
+
log(`Connected. Credentials stored at ${file} (owner-only).`);
|
|
67
|
+
log(`Scopes granted: ${creds.scope || scopes.join(' ')}`);
|
|
68
|
+
return creds;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export async function logout({ log = (m) => console.error(m), revoke = true } = {}) {
|
|
72
|
+
const creds = loadCredentials();
|
|
73
|
+
if (!creds) { log('No stored credentials to remove.'); return false; }
|
|
74
|
+
|
|
75
|
+
if (revoke) {
|
|
76
|
+
try {
|
|
77
|
+
const meta = await discover(creds.issuer);
|
|
78
|
+
const ok = await revokeToken(meta, {
|
|
79
|
+
token: creds.refreshToken || creds.accessToken,
|
|
80
|
+
tokenTypeHint: creds.refreshToken ? 'refresh_token' : 'access_token',
|
|
81
|
+
});
|
|
82
|
+
log(ok
|
|
83
|
+
? 'Revoked the authorization on the server.'
|
|
84
|
+
: 'Could not revoke on the server — revoke it in Settings → API Access.');
|
|
85
|
+
} catch (err) {
|
|
86
|
+
log(`Could not reach the server to revoke (${err.message}).`);
|
|
87
|
+
log('The grant is still live — revoke it in Settings → API Access.');
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const removed = clearCredentials();
|
|
92
|
+
log(removed ? `Removed ${credentialsPath()}.` : 'No local credentials file to remove.');
|
|
93
|
+
return removed;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function status({ log = (m) => console.error(m) } = {}) {
|
|
97
|
+
const creds = loadCredentials();
|
|
98
|
+
if (!creds) { log('Not connected. Run: motorical-mcp login'); return null; }
|
|
99
|
+
log(`Connected to ${creds.issuer}`);
|
|
100
|
+
log(` resource: ${creds.resource}`);
|
|
101
|
+
log(` scopes: ${creds.scope || '(unknown)'}`);
|
|
102
|
+
log(` expires: ${new Date(creds.expiresAt).toISOString()}`);
|
|
103
|
+
log(` file: ${credentialsPath()}`);
|
|
104
|
+
return creds;
|
|
105
|
+
}
|
package/src/oauth.js
ADDED
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OAuth 2.1 + PKCE login for the Motorical MCP server.
|
|
3
|
+
*
|
|
4
|
+
* Replaces pasted mk_live_/ak_live_ keys: the user authorizes once in a
|
|
5
|
+
* browser and the resulting grant is stored locally and refreshed silently.
|
|
6
|
+
* The client is public (no secret), so PKCE S256 and refresh rotation are
|
|
7
|
+
* mandatory rather than optional.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import http from 'node:http';
|
|
11
|
+
import crypto from 'node:crypto';
|
|
12
|
+
import fs from 'node:fs';
|
|
13
|
+
import path from 'node:path';
|
|
14
|
+
import os from 'node:os';
|
|
15
|
+
|
|
16
|
+
export const DEFAULT_ISSUER = 'https://motorical.com';
|
|
17
|
+
export const DEFAULT_RESOURCE = 'https://api.motorical.com';
|
|
18
|
+
export const CLIENT_ID = 'https://motorical.com/.well-known/motorical-mcp-client.json';
|
|
19
|
+
|
|
20
|
+
const CREDENTIALS_DIR = path.join(os.homedir(), '.motorical');
|
|
21
|
+
const CREDENTIALS_FILE = path.join(CREDENTIALS_DIR, 'mcp-credentials.json');
|
|
22
|
+
|
|
23
|
+
/** Tokens are user credentials: owner-only, never world-readable. */
|
|
24
|
+
export function saveCredentials(creds, file = CREDENTIALS_FILE) {
|
|
25
|
+
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
26
|
+
fs.writeFileSync(file, JSON.stringify(creds, null, 2), { mode: 0o600 });
|
|
27
|
+
try { fs.chmodSync(file, 0o600); } catch { /* best effort on odd filesystems */ }
|
|
28
|
+
return file;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function loadCredentials(file = CREDENTIALS_FILE) {
|
|
32
|
+
try {
|
|
33
|
+
return JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
34
|
+
} catch {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function clearCredentials(file = CREDENTIALS_FILE) {
|
|
40
|
+
try { fs.unlinkSync(file); return true; } catch { return false; }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function credentialsPath() { return CREDENTIALS_FILE; }
|
|
44
|
+
|
|
45
|
+
export function generatePkce() {
|
|
46
|
+
const verifier = crypto.randomBytes(32).toString('base64url');
|
|
47
|
+
const challenge = crypto.createHash('sha256').update(verifier).digest('base64url');
|
|
48
|
+
return { verifier, challenge };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** RFC 8414 discovery, so endpoints are never hardcoded past the issuer. */
|
|
52
|
+
export async function discover(issuer = DEFAULT_ISSUER, fetchImpl = fetch) {
|
|
53
|
+
const res = await fetchImpl(`${issuer}/.well-known/oauth-authorization-server`, {
|
|
54
|
+
headers: { Accept: 'application/json' },
|
|
55
|
+
});
|
|
56
|
+
if (!res.ok) throw new Error(`Discovery failed: HTTP ${res.status}`);
|
|
57
|
+
const meta = await res.json();
|
|
58
|
+
if (meta.issuer !== issuer) {
|
|
59
|
+
throw new Error(`Issuer mismatch: document says ${meta.issuer}, expected ${issuer}`);
|
|
60
|
+
}
|
|
61
|
+
// A client MUST refuse to continue without PKCE S256 support.
|
|
62
|
+
if (!Array.isArray(meta.code_challenge_methods_supported)
|
|
63
|
+
|| !meta.code_challenge_methods_supported.includes('S256')) {
|
|
64
|
+
throw new Error('Authorization server does not advertise PKCE S256 — refusing to continue');
|
|
65
|
+
}
|
|
66
|
+
return meta;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function buildAuthorizeUrl(meta, { redirectUri, challenge, state, scopes, resource }) {
|
|
70
|
+
const u = new URL(meta.authorization_endpoint);
|
|
71
|
+
u.searchParams.set('client_id', CLIENT_ID);
|
|
72
|
+
u.searchParams.set('response_type', 'code');
|
|
73
|
+
u.searchParams.set('redirect_uri', redirectUri);
|
|
74
|
+
u.searchParams.set('scope', scopes.join(' '));
|
|
75
|
+
u.searchParams.set('state', state);
|
|
76
|
+
u.searchParams.set('code_challenge', challenge);
|
|
77
|
+
u.searchParams.set('code_challenge_method', 'S256');
|
|
78
|
+
u.searchParams.set('resource', resource);
|
|
79
|
+
return u.toString();
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* RFC 9207: the callback carries `iss`. Comparing it to the issuer we
|
|
84
|
+
* discovered is what stops a mix-up attack, where a malicious AS relays the
|
|
85
|
+
* user to a different one and harvests the code.
|
|
86
|
+
*/
|
|
87
|
+
export function validateCallback({ params, expectedState, expectedIssuer }) {
|
|
88
|
+
if (params.get('error')) {
|
|
89
|
+
throw new Error(params.get('error_description') || params.get('error'));
|
|
90
|
+
}
|
|
91
|
+
if (params.get('state') !== expectedState) {
|
|
92
|
+
throw new Error('State mismatch — discarding this callback');
|
|
93
|
+
}
|
|
94
|
+
const iss = params.get('iss');
|
|
95
|
+
if (iss && iss !== expectedIssuer) {
|
|
96
|
+
throw new Error(`Issuer mismatch in callback: ${iss}`);
|
|
97
|
+
}
|
|
98
|
+
const code = params.get('code');
|
|
99
|
+
if (!code) throw new Error('Authorization callback carried no code');
|
|
100
|
+
return code;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export async function exchangeCode(meta, { code, verifier, redirectUri, resource }, fetchImpl = fetch) {
|
|
104
|
+
const body = new URLSearchParams({
|
|
105
|
+
grant_type: 'authorization_code',
|
|
106
|
+
code,
|
|
107
|
+
client_id: CLIENT_ID,
|
|
108
|
+
redirect_uri: redirectUri,
|
|
109
|
+
code_verifier: verifier,
|
|
110
|
+
resource,
|
|
111
|
+
});
|
|
112
|
+
const res = await fetchImpl(meta.token_endpoint, {
|
|
113
|
+
method: 'POST',
|
|
114
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' },
|
|
115
|
+
body: body.toString(),
|
|
116
|
+
});
|
|
117
|
+
const data = await res.json().catch(() => ({}));
|
|
118
|
+
if (!res.ok) throw new Error(data.error_description || data.error || `Token exchange failed: HTTP ${res.status}`);
|
|
119
|
+
return data;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export async function refreshTokens(meta, { refreshToken }, fetchImpl = fetch) {
|
|
123
|
+
const body = new URLSearchParams({
|
|
124
|
+
grant_type: 'refresh_token',
|
|
125
|
+
refresh_token: refreshToken,
|
|
126
|
+
client_id: CLIENT_ID,
|
|
127
|
+
});
|
|
128
|
+
const res = await fetchImpl(meta.token_endpoint, {
|
|
129
|
+
method: 'POST',
|
|
130
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' },
|
|
131
|
+
body: body.toString(),
|
|
132
|
+
});
|
|
133
|
+
const data = await res.json().catch(() => ({}));
|
|
134
|
+
if (!res.ok) throw new Error(data.error_description || data.error || `Refresh failed: HTTP ${res.status}`);
|
|
135
|
+
return data;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* RFC 7009. Deleting the local file leaves the grant alive on the server —
|
|
140
|
+
* still valid, still refreshable, belonging to nobody. "Log out" should mean
|
|
141
|
+
* the access is gone, so revoke before forgetting.
|
|
142
|
+
*/
|
|
143
|
+
export async function revokeToken(meta, { token, tokenTypeHint = 'refresh_token' }, fetchImpl = fetch) {
|
|
144
|
+
if (!meta?.revocation_endpoint) return false;
|
|
145
|
+
const body = new URLSearchParams({
|
|
146
|
+
token, token_type_hint: tokenTypeHint, client_id: CLIENT_ID,
|
|
147
|
+
});
|
|
148
|
+
const res = await fetchImpl(meta.revocation_endpoint, {
|
|
149
|
+
method: 'POST',
|
|
150
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
151
|
+
body: body.toString(),
|
|
152
|
+
});
|
|
153
|
+
return res.ok;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Shape stored on disk; `expiresAt` is absolute so a stale clock is obvious. */
|
|
157
|
+
export function toStoredCredentials(tokenResponse, { issuer, resource }) {
|
|
158
|
+
return {
|
|
159
|
+
version: 1,
|
|
160
|
+
issuer,
|
|
161
|
+
resource,
|
|
162
|
+
accessToken: tokenResponse.access_token,
|
|
163
|
+
refreshToken: tokenResponse.refresh_token || null,
|
|
164
|
+
scope: tokenResponse.scope || '',
|
|
165
|
+
expiresAt: Date.now() + (Number(tokenResponse.expires_in || 3600) * 1000),
|
|
166
|
+
obtainedAt: Date.now(),
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** 60s skew so a token is never used in the instant before it expires. */
|
|
171
|
+
export function isExpired(creds, now = Date.now()) {
|
|
172
|
+
if (!creds?.expiresAt) return true;
|
|
173
|
+
return now >= (creds.expiresAt - 60_000);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Starts the loopback listener and returns the chosen port immediately, since
|
|
178
|
+
* the port has to go into the authorize URL before any code can arrive.
|
|
179
|
+
*/
|
|
180
|
+
export function startCallbackServer({ expectedState, expectedIssuer, timeoutMs = 300_000 }) {
|
|
181
|
+
return new Promise((resolveStart, rejectStart) => {
|
|
182
|
+
let settle;
|
|
183
|
+
const waitForCode = new Promise((resolve, reject) => { settle = { resolve, reject }; });
|
|
184
|
+
|
|
185
|
+
const server = http.createServer((req, res) => {
|
|
186
|
+
const url = new URL(req.url, 'http://127.0.0.1');
|
|
187
|
+
if (url.pathname !== '/callback') { res.writeHead(404).end('Not found'); return; }
|
|
188
|
+
try {
|
|
189
|
+
const code = validateCallback({ params: url.searchParams, expectedState, expectedIssuer });
|
|
190
|
+
res.writeHead(200, { 'Content-Type': 'text/html' }).end(page(
|
|
191
|
+
'Motorical is connected',
|
|
192
|
+
'You can close this tab and return to your terminal.'
|
|
193
|
+
));
|
|
194
|
+
finish(); settle.resolve(code);
|
|
195
|
+
} catch (err) {
|
|
196
|
+
res.writeHead(400, { 'Content-Type': 'text/html' }).end(page(
|
|
197
|
+
'Authorization failed', escapeHtml(err.message)
|
|
198
|
+
));
|
|
199
|
+
finish(); settle.reject(err);
|
|
200
|
+
}
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
const timer = setTimeout(() => {
|
|
204
|
+
finish();
|
|
205
|
+
settle.reject(new Error('Timed out waiting for the browser callback'));
|
|
206
|
+
}, timeoutMs);
|
|
207
|
+
|
|
208
|
+
function finish() {
|
|
209
|
+
clearTimeout(timer);
|
|
210
|
+
setImmediate(() => server.close(() => {}));
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
server.on('error', (err) => { clearTimeout(timer); rejectStart(err); });
|
|
214
|
+
// Port 0 = whatever the OS gives us; the AS matches loopback ignoring port.
|
|
215
|
+
server.listen(0, '127.0.0.1', () => {
|
|
216
|
+
const { port } = server.address();
|
|
217
|
+
resolveStart({
|
|
218
|
+
port,
|
|
219
|
+
redirectUri: `http://127.0.0.1:${port}/callback`,
|
|
220
|
+
waitForCode,
|
|
221
|
+
close: finish,
|
|
222
|
+
});
|
|
223
|
+
});
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function page(title, body) {
|
|
228
|
+
return `<html><body style="font-family:system-ui;padding:3rem;text-align:center"><h2>${title}</h2><p>${body}</p></body></html>`;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function escapeHtml(s) {
|
|
232
|
+
return String(s).replace(/[&<>"']/g, (c) => (
|
|
233
|
+
{ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]
|
|
234
|
+
));
|
|
235
|
+
}
|
package/src/server.js
CHANGED
|
@@ -34,7 +34,19 @@ export function createMotoricalMcpServer(options = {}) {
|
|
|
34
34
|
version: PACKAGE_VERSION
|
|
35
35
|
});
|
|
36
36
|
|
|
37
|
-
|
|
37
|
+
// The stdio/CLI entrypoint (index.js) calls this with no allowedTools and
|
|
38
|
+
// gets every tool, unaffected. The HTTP resource server (http.js) passes
|
|
39
|
+
// the connected server's own tool list: without this, tools/list would
|
|
40
|
+
// advertise every tool on every path — e.g. motorical_send_email on the
|
|
41
|
+
// analytics-only server — even though calling it there would be refused.
|
|
42
|
+
// The advertisement must match what's actually callable.
|
|
43
|
+
const allowedTools = options.allowedTools || null;
|
|
44
|
+
function registerTool(name, config, cb) {
|
|
45
|
+
if (allowedTools && !allowedTools.includes(name)) return;
|
|
46
|
+
server.registerTool(name, config, cb);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
registerTool(
|
|
38
50
|
'motorical_get_send_status',
|
|
39
51
|
{
|
|
40
52
|
description:
|
|
@@ -50,7 +62,7 @@ export function createMotoricalMcpServer(options = {}) {
|
|
|
50
62
|
}
|
|
51
63
|
);
|
|
52
64
|
|
|
53
|
-
|
|
65
|
+
registerTool(
|
|
54
66
|
'motorical_mint_public_token',
|
|
55
67
|
{
|
|
56
68
|
description:
|
|
@@ -72,13 +84,13 @@ export function createMotoricalMcpServer(options = {}) {
|
|
|
72
84
|
}
|
|
73
85
|
);
|
|
74
86
|
|
|
75
|
-
|
|
87
|
+
registerTool(
|
|
76
88
|
'motorical_list_motor_blocks',
|
|
77
89
|
{
|
|
78
90
|
description:
|
|
79
91
|
'List Motor Blocks (isolated sending streams) visible to a Public API bearer token (auto-mints with ak_live_ if needed).',
|
|
80
92
|
inputSchema: {
|
|
81
|
-
motorBlockId: z.string().uuid().optional().describe('
|
|
93
|
+
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
94
|
}
|
|
83
95
|
},
|
|
84
96
|
async (args) => {
|
|
@@ -90,7 +102,7 @@ export function createMotoricalMcpServer(options = {}) {
|
|
|
90
102
|
}
|
|
91
103
|
);
|
|
92
104
|
|
|
93
|
-
|
|
105
|
+
registerTool(
|
|
94
106
|
'motorical_send_email',
|
|
95
107
|
{
|
|
96
108
|
description:
|
|
@@ -115,7 +127,12 @@ export function createMotoricalMcpServer(options = {}) {
|
|
|
115
127
|
.boolean()
|
|
116
128
|
.optional()
|
|
117
129
|
.describe('Required true when dryRun is false'),
|
|
118
|
-
idempotencyKey: z.string().optional()
|
|
130
|
+
idempotencyKey: z.string().optional(),
|
|
131
|
+
motorBlockId: z
|
|
132
|
+
.string()
|
|
133
|
+
.uuid()
|
|
134
|
+
.optional()
|
|
135
|
+
.describe('Required when the authorization covers more than one Motor Block')
|
|
119
136
|
}
|
|
120
137
|
},
|
|
121
138
|
async (args) => {
|
|
@@ -127,7 +144,7 @@ export function createMotoricalMcpServer(options = {}) {
|
|
|
127
144
|
}
|
|
128
145
|
);
|
|
129
146
|
|
|
130
|
-
|
|
147
|
+
registerTool(
|
|
131
148
|
'motorical_get_message',
|
|
132
149
|
{
|
|
133
150
|
description: 'Get a message by send UUID (GET /api/public/v1/messages/{id}). Auto-mints bearer if needed.',
|
|
@@ -146,7 +163,7 @@ export function createMotoricalMcpServer(options = {}) {
|
|
|
146
163
|
}
|
|
147
164
|
);
|
|
148
165
|
|
|
149
|
-
|
|
166
|
+
registerTool(
|
|
150
167
|
'motorical_get_message_events',
|
|
151
168
|
{
|
|
152
169
|
description:
|
|
@@ -166,7 +183,7 @@ export function createMotoricalMcpServer(options = {}) {
|
|
|
166
183
|
}
|
|
167
184
|
);
|
|
168
185
|
|
|
169
|
-
|
|
186
|
+
registerTool(
|
|
170
187
|
'motorical_sandbox_status',
|
|
171
188
|
{
|
|
172
189
|
description:
|
|
@@ -186,7 +203,7 @@ export function createMotoricalMcpServer(options = {}) {
|
|
|
186
203
|
}
|
|
187
204
|
);
|
|
188
205
|
|
|
189
|
-
|
|
206
|
+
registerTool(
|
|
190
207
|
'motorical_sandbox_allowlist_request',
|
|
191
208
|
{
|
|
192
209
|
description:
|
|
@@ -206,7 +223,7 @@ export function createMotoricalMcpServer(options = {}) {
|
|
|
206
223
|
}
|
|
207
224
|
);
|
|
208
225
|
|
|
209
|
-
|
|
226
|
+
registerTool(
|
|
210
227
|
'motorical_sandbox_allowlist_confirm',
|
|
211
228
|
{
|
|
212
229
|
description:
|
|
@@ -227,7 +244,7 @@ export function createMotoricalMcpServer(options = {}) {
|
|
|
227
244
|
}
|
|
228
245
|
);
|
|
229
246
|
|
|
230
|
-
|
|
247
|
+
registerTool(
|
|
231
248
|
'motorical_sandbox_provision',
|
|
232
249
|
{
|
|
233
250
|
description:
|
|
@@ -247,7 +264,7 @@ export function createMotoricalMcpServer(options = {}) {
|
|
|
247
264
|
}
|
|
248
265
|
);
|
|
249
266
|
|
|
250
|
-
|
|
267
|
+
registerTool(
|
|
251
268
|
'motorical_sandbox_convert',
|
|
252
269
|
{
|
|
253
270
|
description:
|
|
@@ -267,7 +284,7 @@ export function createMotoricalMcpServer(options = {}) {
|
|
|
267
284
|
}
|
|
268
285
|
);
|
|
269
286
|
|
|
270
|
-
|
|
287
|
+
registerTool(
|
|
271
288
|
'motorical_domain_add',
|
|
272
289
|
{
|
|
273
290
|
description:
|
|
@@ -277,7 +294,12 @@ export function createMotoricalMcpServer(options = {}) {
|
|
|
277
294
|
'this account before asking the user to resolve the conflict. Requires MOTORICAL_JWT.',
|
|
278
295
|
inputSchema: {
|
|
279
296
|
domain: z.string().min(3),
|
|
280
|
-
verificationMethod: z.enum(['dns', 'email']).optional()
|
|
297
|
+
verificationMethod: z.enum(['dns', 'email']).optional(),
|
|
298
|
+
motorBlockId: z
|
|
299
|
+
.string()
|
|
300
|
+
.uuid()
|
|
301
|
+
.optional()
|
|
302
|
+
.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
303
|
}
|
|
282
304
|
},
|
|
283
305
|
async (args) => {
|
|
@@ -289,25 +311,31 @@ export function createMotoricalMcpServer(options = {}) {
|
|
|
289
311
|
}
|
|
290
312
|
);
|
|
291
313
|
|
|
292
|
-
|
|
314
|
+
registerTool(
|
|
293
315
|
'motorical_domain_list',
|
|
294
316
|
{
|
|
295
317
|
description:
|
|
296
318
|
'List domains already on this account (GET /api/domains) — id, domain, verified, DNS auth flags. ' +
|
|
297
319
|
'Call this before motorical_domain_add on a 409 conflict to self-diagnose whether the domain is already ' +
|
|
298
320
|
'yours (proceed with the existing id) or genuinely owned by someone else (stop, do not guess). Requires MOTORICAL_JWT.',
|
|
299
|
-
inputSchema: {
|
|
321
|
+
inputSchema: {
|
|
322
|
+
motorBlockId: z
|
|
323
|
+
.string()
|
|
324
|
+
.uuid()
|
|
325
|
+
.optional()
|
|
326
|
+
.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.')
|
|
327
|
+
}
|
|
300
328
|
},
|
|
301
|
-
async () => {
|
|
329
|
+
async (args) => {
|
|
302
330
|
try {
|
|
303
|
-
return jsonResult(await client.domainList());
|
|
331
|
+
return jsonResult(await client.domainList(args));
|
|
304
332
|
} catch (err) {
|
|
305
333
|
return errorResult(err);
|
|
306
334
|
}
|
|
307
335
|
}
|
|
308
336
|
);
|
|
309
337
|
|
|
310
|
-
|
|
338
|
+
registerTool(
|
|
311
339
|
'motorical_domain_verify',
|
|
312
340
|
{
|
|
313
341
|
description:
|
|
@@ -315,7 +343,12 @@ export function createMotoricalMcpServer(options = {}) {
|
|
|
315
343
|
'Safe to re-call after ownership is done — returns sendReady. Requires MOTORICAL_JWT.',
|
|
316
344
|
inputSchema: {
|
|
317
345
|
domainId: z.string().uuid(),
|
|
318
|
-
method: z.enum(['dns', 'email']).optional()
|
|
346
|
+
method: z.enum(['dns', 'email']).optional(),
|
|
347
|
+
motorBlockId: z
|
|
348
|
+
.string()
|
|
349
|
+
.uuid()
|
|
350
|
+
.optional()
|
|
351
|
+
.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
352
|
}
|
|
320
353
|
},
|
|
321
354
|
async (args) => {
|
|
@@ -327,7 +360,7 @@ export function createMotoricalMcpServer(options = {}) {
|
|
|
327
360
|
}
|
|
328
361
|
);
|
|
329
362
|
|
|
330
|
-
|
|
363
|
+
registerTool(
|
|
331
364
|
'motorical_domain_check_dns',
|
|
332
365
|
{
|
|
333
366
|
description:
|
|
@@ -335,7 +368,12 @@ export function createMotoricalMcpServer(options = {}) {
|
|
|
335
368
|
'Required before /v1/send when ownership is verified but send returns DOMAIN_DNS_INCOMPLETE. Requires MOTORICAL_JWT.',
|
|
336
369
|
inputSchema: {
|
|
337
370
|
domainId: z.string().uuid(),
|
|
338
|
-
recordType: z.enum(['dkim', 'spf', 'dmarc', 'mx']).optional()
|
|
371
|
+
recordType: z.enum(['dkim', 'spf', 'dmarc', 'mx']).optional(),
|
|
372
|
+
motorBlockId: z
|
|
373
|
+
.string()
|
|
374
|
+
.uuid()
|
|
375
|
+
.optional()
|
|
376
|
+
.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
377
|
}
|
|
340
378
|
},
|
|
341
379
|
async (args) => {
|
|
@@ -347,7 +385,7 @@ export function createMotoricalMcpServer(options = {}) {
|
|
|
347
385
|
}
|
|
348
386
|
);
|
|
349
387
|
|
|
350
|
-
|
|
388
|
+
registerTool(
|
|
351
389
|
'motorical_web_handoff',
|
|
352
390
|
{
|
|
353
391
|
description:
|