@motorical/mcp 1.2.0 → 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.
Files changed (3) hide show
  1. package/package.json +2 -2
  2. package/src/client.js +112 -28
  3. package/src/server.js +62 -24
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@motorical/mcp",
3
- "version": "1.2.0",
4
- "description": "MCP server for Motorical transactional email API \u2014 dry-run/send, mint public tokens, list Motor Blocks, inspect delivery events",
3
+ "version": "1.2.1",
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
- async request(method, path, { headers = {}, body, apiKey, bearer } = {}) {
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,22 @@ 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
+
169
211
  async getBearer({ motorBlockId, forceRefresh = false } = {}) {
170
212
  // An OAuth grant supersedes minted public tokens entirely — no ak_live_ key
171
213
  // and no dashboard JWT needed.
@@ -182,7 +224,7 @@ export class MotoricalClient {
182
224
 
183
225
  async listMotorBlocks({ motorBlockId } = {}) {
184
226
  const bearer = await this.getBearer({ motorBlockId });
185
- return this.request('GET', this._scoped('/api/public/v1/motor-blocks', motorBlockId), { bearer });
227
+ return this.request('GET', this._accountPath('/api/public/v1/motor-blocks', motorBlockId), { bearer });
186
228
  }
187
229
 
188
230
  async sendEmail(payload) {
@@ -197,6 +239,7 @@ export class MotoricalClient {
197
239
  confirmRealSend = false,
198
240
  idempotencyKey,
199
241
  headers: customHeaders,
242
+ motorBlockId: _motorBlockId, // never in the body — see sendPath below
200
243
  ...rest
201
244
  } = payload;
202
245
 
@@ -310,44 +353,85 @@ export class MotoricalClient {
310
353
  });
311
354
  }
312
355
 
313
- async domainList() {
314
- // An OAuth grant reads the scoped public endpoint. It cannot use
315
- // /api/domains: that route is behind the dashboard-session middleware,
316
- // which also guards billing and account settings.
317
- const oauthToken = await this.oauthAccessToken();
318
- if (oauthToken) {
319
- // Domains are account-wide, but authenticatePublic keeps MCP grants
320
- // strictly block-bound rather than silently choosing one, so the
321
- // selector travels here too. The endpoint scopes by user, not by block.
322
- return this.request('GET', this._scoped('/api/public/v1/domains'), { bearer: oauthToken });
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() });
323
385
  }
324
- return this.request('GET', '/api/domains', {
325
- bearer: this.requireDashboardJwt()
326
- });
386
+ const bearer = await this.getBearer({ motorBlockId });
387
+ return this.request('GET', this._accountPath('/api/public/v1/domains', motorBlockId), { bearer });
327
388
  }
328
389
 
329
- async domainAdd({ domain, verificationMethod = 'dns' } = {}) {
390
+ async domainAdd({ domain, verificationMethod = 'dns', motorBlockId } = {}) {
330
391
  if (!domain) throw new Error('domain is required');
331
- return this.request('POST', '/api/domains', {
332
- bearer: this.requireDashboardJwt(),
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,
333
401
  body: { domain, verificationMethod }
334
402
  });
335
403
  }
336
404
 
337
- async domainVerify({ domainId, method = 'dns' } = {}) {
405
+ async domainVerify({ domainId, method = 'dns', motorBlockId } = {}) {
338
406
  if (!domainId) throw new Error('domainId is required');
339
- return this.request('POST', `/api/domains/${encodeURIComponent(domainId)}/verify`, {
340
- bearer: this.requireDashboardJwt(),
341
- body: { method }
342
- });
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
+ );
343
419
  }
344
420
 
345
- async domainCheckDns({ domainId, recordType } = {}) {
421
+ async domainCheckDns({ domainId, recordType, motorBlockId } = {}) {
346
422
  if (!domainId) throw new Error('domainId is required');
347
- return this.request('POST', `/api/domains/${encodeURIComponent(domainId)}/check-dns`, {
348
- bearer: this.requireDashboardJwt(),
349
- body: recordType ? { recordType } : {}
350
- });
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
+ );
351
435
  }
352
436
 
353
437
  async sandboxAllowlistRequest({ email } = {}) {
package/src/server.js CHANGED
@@ -34,7 +34,19 @@ export function createMotoricalMcpServer(options = {}) {
34
34
  version: PACKAGE_VERSION
35
35
  });
36
36
 
37
- server.registerTool(
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
- server.registerTool(
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
- server.registerTool(
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('Block used when minting a token if none cached')
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
- server.registerTool(
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
- server.registerTool(
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
- server.registerTool(
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
- server.registerTool(
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
- server.registerTool(
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
- server.registerTool(
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
- server.registerTool(
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
- server.registerTool(
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
- server.registerTool(
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
- server.registerTool(
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
- server.registerTool(
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
- server.registerTool(
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
- server.registerTool(
388
+ registerTool(
351
389
  'motorical_web_handoff',
352
390
  {
353
391
  description: