@motorical/scope-catalog 0.1.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/README.md ADDED
@@ -0,0 +1,13 @@
1
+ # @motorical/scope-catalog
2
+
3
+ Canonical MCP/OAuth scope metadata for Motorical. One source of truth for:
4
+
5
+ - what each of the seven grantable MCP scopes means, for an agent AND for a customer (`SCOPES`)
6
+ - which Public API scopes each MCP scope unlocks (`SCOPES[id].publicScopes`, `publicScopesFor`)
7
+ - "manage implies read", in both the MCP and Public API vocabularies (`implication.js`)
8
+ - which scopes a tool needs, and which scopes a given MCP resource server allows (`tools.js`)
9
+ - how to group scopes for a consent screen (`groupForConsent`)
10
+
11
+ Consumed by `motorical-backend` (OAuth server, Public API auth) and `@motorical/mcp`
12
+ (the hosted/local MCP server). See `motorical-docs/specs/2026-09-23-mcp-p7-scope-legibility-founding-doc.md`
13
+ for why this package exists.
package/package.json ADDED
@@ -0,0 +1,36 @@
1
+ {
2
+ "name": "@motorical/scope-catalog",
3
+ "version": "0.1.0",
4
+ "description": "Canonical MCP/OAuth scope metadata for Motorical — descriptions, Public API scope mapping, implied-read rules, and declarative tool/resource membership. One source of truth consumed by motorical-backend and @motorical/mcp.",
5
+ "main": "./src/index.js",
6
+ "engines": {
7
+ "node": ">=20"
8
+ },
9
+ "scripts": {
10
+ "test": "node --test test/*.test.js"
11
+ },
12
+ "license": "UNLICENSED",
13
+ "dependencies": {},
14
+ "files": [
15
+ "src",
16
+ "README.md"
17
+ ],
18
+ "homepage": "https://docs.motorical.com/ai",
19
+ "bugs": {
20
+ "url": "https://github.com/motorical-smtp/motorical-backend/issues"
21
+ },
22
+ "keywords": [
23
+ "motorical",
24
+ "oauth",
25
+ "scopes",
26
+ "mcp"
27
+ ],
28
+ "publishConfig": {
29
+ "access": "public"
30
+ },
31
+ "repository": {
32
+ "type": "git",
33
+ "url": "git+https://github.com/motorical-smtp/motorical-packages.git",
34
+ "directory": "packages/motorical-scope-catalog"
35
+ }
36
+ }
package/src/consent.js ADDED
@@ -0,0 +1,40 @@
1
+ const { SCOPES } = require('./scopes');
2
+
3
+ const LEVEL_RANK = { read: 0, manage: 1 };
4
+
5
+ /**
6
+ * "Granular and secure internally; simple for the customer" (P7 design
7
+ * principle 1). The underlying scopes stay separately grantable and
8
+ * separately enforced; this only changes what the consent screen SHOWS —
9
+ * one line per resource area, using whichever granted scope for that
10
+ * resource has the highest level, while `scopes` on the returned group
11
+ * still lists every individual scope actually granted for it.
12
+ */
13
+ function groupForConsent(scopes) {
14
+ const byResource = new Map();
15
+ for (const id of scopes) {
16
+ const meta = SCOPES[id];
17
+ if (!meta) continue;
18
+ const existing = byResource.get(meta.resource);
19
+ if (!existing || LEVEL_RANK[meta.level] > LEVEL_RANK[existing.level]) {
20
+ byResource.set(meta.resource, { resource: meta.resource, label: meta.customerAction, level: meta.level, scopes: [] });
21
+ }
22
+ }
23
+ for (const id of scopes) {
24
+ const meta = SCOPES[id];
25
+ if (!meta) continue;
26
+ byResource.get(meta.resource).scopes.push(id);
27
+ }
28
+ return Array.from(byResource.values()).map(({ resource, label, scopes: s }) => ({ resource, label, scopes: s }));
29
+ }
30
+
31
+ function publicScopesFor(mcpScopes) {
32
+ const out = new Set();
33
+ for (const id of mcpScopes) {
34
+ for (const p of (SCOPES[id]?.publicScopes || [])) out.add(p);
35
+ }
36
+ out.delete('logs.pii');
37
+ return Array.from(out);
38
+ }
39
+
40
+ module.exports = { groupForConsent, publicScopesFor };
@@ -0,0 +1,44 @@
1
+ /**
2
+ * "Manage implies read" at the MCP layer. Was resourceAuth.js's
3
+ * IMPLIED_READ_MCP_SCOPES (packages/motorical-mcp/src/resourceAuth.js).
4
+ * Kept separate from PUBLIC_IMPLIED_READ below because the two vocabularies
5
+ * are genuinely different namespaces — see that map's own comment.
6
+ */
7
+ const MCP_IMPLIED_READ = {
8
+ 'manage:webhooks': 'read:webhooks',
9
+ 'manage:domains': 'read:domains',
10
+ };
11
+
12
+ function expandMcpImplied(grantedScopes) {
13
+ const expanded = new Set(grantedScopes);
14
+ for (const manageScope of Object.keys(MCP_IMPLIED_READ)) {
15
+ if (expanded.has(manageScope)) expanded.add(MCP_IMPLIED_READ[manageScope]);
16
+ }
17
+ return Array.from(expanded);
18
+ }
19
+
20
+ /**
21
+ * "Manage implies read" at the Public API layer. Was authenticatePublic.js's
22
+ * IMPLIED_READ_SCOPES (backend/src/middleware/authenticatePublic.js).
23
+ *
24
+ * This is NOT derivable from MCP_IMPLIED_READ above: this map also has to
25
+ * cover callers that never touch an MCP scope at all (a legacy dashboard-JWT
26
+ * Public API token, "authenticatePublicMcpAudience.test.js"'s "legacy HS256
27
+ * tokens keep working unchanged" case) — so it has to exist as its own
28
+ * independent fact about the Public API's own scope vocabulary, not as a
29
+ * side effect of the MCP-scope map.
30
+ */
31
+ const PUBLIC_IMPLIED_READ = {
32
+ 'webhooks.manage': 'webhooks.read',
33
+ 'config.manage': 'config.read',
34
+ };
35
+
36
+ function expandPublicImplied(grantedScopes) {
37
+ const expanded = new Set(grantedScopes);
38
+ for (const manageScope of Object.keys(PUBLIC_IMPLIED_READ)) {
39
+ if (expanded.has(manageScope)) expanded.add(PUBLIC_IMPLIED_READ[manageScope]);
40
+ }
41
+ return Array.from(expanded);
42
+ }
43
+
44
+ module.exports = { MCP_IMPLIED_READ, expandMcpImplied, PUBLIC_IMPLIED_READ, expandPublicImplied };
package/src/index.js ADDED
@@ -0,0 +1,6 @@
1
+ module.exports = {
2
+ ...require('./scopes'),
3
+ ...require('./implication'),
4
+ ...require('./tools'),
5
+ ...require('./consent'),
6
+ };
package/src/scopes.js ADDED
@@ -0,0 +1,76 @@
1
+ // packages/motorical-scope-catalog/src/scopes.js
2
+
3
+ /**
4
+ * The one source of truth for what an MCP scope IS: which resource area it
5
+ * belongs to, whether it's read or manage, which Public API scopes it
6
+ * unlocks (backend/src/services/oauth/mcpTokens.js used to hand-declare
7
+ * this as MCP_TO_PUBLIC_SCOPES), and the two audiences that need to
8
+ * understand it in different words — an agent deciding what to request,
9
+ * and a customer deciding what to approve.
10
+ *
11
+ * publicScopes values below are copied verbatim from the MCP_TO_PUBLIC_SCOPES
12
+ * this package replaces. logs.pii is deliberately absent everywhere here —
13
+ * it is never reachable from any MCP scope, by design (P6).
14
+ */
15
+ // Insertion order matters: Object.keys(SCOPES) drives listGrantableScopes(),
16
+ // which backend/src/routes/oauth.js's MCP_SCOPES_SUPPORTED derives from
17
+ // (Task 9). oauthDiscoveryMetadata.test.js asserts scopes_supported as an
18
+ // EXACT ordered array — this order matches that existing test byte-for-byte
19
+ // so that test needs no change. Do not reorder without checking it first.
20
+ const SCOPES = {
21
+ 'send:transactional': {
22
+ resource: 'transactional',
23
+ level: 'manage',
24
+ publicScopes: [],
25
+ agentDescription: 'Send transactional email through the caller\'s Motor Block(s).',
26
+ customerAction: 'Send transactional email through your Motor Blocks',
27
+ },
28
+ 'read:analytics': {
29
+ resource: 'analytics',
30
+ level: 'read',
31
+ publicScopes: ['logs.read', 'analytics.read', 'usage.read'],
32
+ agentDescription: 'Read delivery logs, analytics, and usage for the caller\'s Motor Block(s). Never includes unmasked recipient PII (logs.pii) regardless of what is requested.',
33
+ customerAction: 'Read delivery logs, analytics and usage',
34
+ },
35
+ 'manage:domains': {
36
+ resource: 'domains',
37
+ level: 'manage',
38
+ publicScopes: ['config.read', 'config.manage'],
39
+ agentDescription: 'Add, verify, and read sending domains and their DNS health. Includes everything read:domains grants.',
40
+ customerAction: 'Add and verify sending domains',
41
+ },
42
+ 'manage:sandbox': {
43
+ resource: 'sandbox',
44
+ level: 'manage',
45
+ publicScopes: ['sandbox.manage'],
46
+ agentDescription: 'Check, provision, and convert the developer sandbox. Sandbox allowlist requests/confirmations additionally require a dashboard session — this scope alone does not make them callable over OAuth.',
47
+ customerAction: 'Manage your developer sandbox',
48
+ },
49
+ 'manage:webhooks': {
50
+ resource: 'webhooks',
51
+ level: 'manage',
52
+ publicScopes: ['webhooks.manage'],
53
+ agentDescription: 'Create, update, delete, and test webhook endpoints on the caller\'s Motor Block(s). Includes everything read:webhooks grants.',
54
+ customerAction: 'Manage your webhook endpoints',
55
+ },
56
+ 'read:webhooks': {
57
+ resource: 'webhooks',
58
+ level: 'read',
59
+ publicScopes: ['webhooks.read'],
60
+ agentDescription: 'List webhook endpoints and read their delivery history and stats, without creating, updating, deleting, or testing any.',
61
+ customerAction: 'View your webhook endpoints and delivery history',
62
+ },
63
+ 'read:domains': {
64
+ resource: 'domains',
65
+ level: 'read',
66
+ publicScopes: ['config.read'],
67
+ agentDescription: 'List and read sending domains, without adding or verifying any.',
68
+ customerAction: 'View your sending domains',
69
+ },
70
+ };
71
+
72
+ function listGrantableScopes() {
73
+ return Object.keys(SCOPES);
74
+ }
75
+
76
+ module.exports = { SCOPES, listGrantableScopes };
package/src/tools.js ADDED
@@ -0,0 +1,149 @@
1
+ /**
2
+ * Declarative tool/resource membership — moved out of
3
+ * packages/motorical-mcp/src/servers.js (P7 second review round: that
4
+ * file's own comment already said "a server's advertised scopes are the
5
+ * union of its tools' required scopes," so a second hand-maintained
6
+ * resource->scopes map would have duplicated exactly that logic).
7
+ *
8
+ * TOOL_ROUTES, LOCAL_ONLY_TOOLS, HOSTED_ONLY_TOOLS, PROMPT_TOOLS,
9
+ * LOCAL_SERVER_TOOL_COUNT, ACCOUNT_SCOPED_TOOLS, byPath, and
10
+ * canonicalUriFor all stay in servers.js — those are @motorical/mcp's own
11
+ * implementation wiring, not shared policy. Only the tool-name lists, the
12
+ * scope map, and their derivation move here.
13
+ */
14
+ const MCP_HOST = 'https://mcp.motorical.com';
15
+
16
+ const TRANSACTIONAL_TOOLS = [
17
+ 'motorical_send_email',
18
+ 'motorical_get_message',
19
+ 'motorical_get_message_events',
20
+ 'motorical_wait_for_outcome',
21
+ 'motorical_get_send_status',
22
+ 'motorical_integrate_send',
23
+ ];
24
+
25
+ const ANALYTICS_TOOLS = [
26
+ 'motorical_list_motor_blocks',
27
+ 'motorical_get_overview',
28
+ 'motorical_get_daily_summary',
29
+ 'motorical_get_metrics',
30
+ 'motorical_get_deliverability',
31
+ 'motorical_get_reputation',
32
+ 'motorical_get_anomalies',
33
+ 'motorical_get_providers',
34
+ 'motorical_get_error_codes',
35
+ 'motorical_get_rate_limits',
36
+ 'motorical_get_account_rate_limits',
37
+ 'motorical_get_onboarding_state',
38
+ 'motorical_get_logs',
39
+ 'motorical_get_message_by_smtp_id',
40
+ ];
41
+
42
+ const DOMAIN_TOOLS = [
43
+ 'motorical_domain_list',
44
+ 'motorical_domain_add',
45
+ 'motorical_domain_verify',
46
+ 'motorical_domain_check_dns',
47
+ 'motorical_get_config',
48
+ 'motorical_get_domain_health',
49
+ ];
50
+
51
+ const SANDBOX_TOOLS = [
52
+ 'motorical_sandbox_status',
53
+ 'motorical_sandbox_provision',
54
+ 'motorical_sandbox_convert',
55
+ 'motorical_sandbox_allowlist_request',
56
+ 'motorical_sandbox_allowlist_confirm',
57
+ ];
58
+
59
+ const WEBHOOK_TOOLS = [
60
+ 'motorical_webhook_list',
61
+ 'motorical_webhook_create',
62
+ 'motorical_webhook_update',
63
+ 'motorical_webhook_delete',
64
+ 'motorical_webhook_test',
65
+ 'motorical_webhook_get_deliveries',
66
+ 'motorical_webhook_get_stats',
67
+ ];
68
+
69
+ const ALL_TOOLS = [
70
+ ...new Set([...TRANSACTIONAL_TOOLS, ...ANALYTICS_TOOLS, ...DOMAIN_TOOLS, ...SANDBOX_TOOLS, ...WEBHOOK_TOOLS]),
71
+ ];
72
+
73
+ const SERVER_TOOLS = {
74
+ main: ALL_TOOLS,
75
+ transactional: TRANSACTIONAL_TOOLS,
76
+ analytics: ANALYTICS_TOOLS,
77
+ domains: DOMAIN_TOOLS,
78
+ sandbox: SANDBOX_TOOLS,
79
+ webhooks: WEBHOOK_TOOLS,
80
+ signup: ['motorical_signup_handoff'],
81
+ };
82
+
83
+ /** The one source of truth for "what scope does this tool need". */
84
+ const TOOL_SCOPES = {
85
+ motorical_send_email: ['send:transactional'],
86
+ motorical_integrate_send: ['send:transactional'],
87
+ motorical_get_message: ['read:analytics'],
88
+ motorical_get_message_events: ['read:analytics'],
89
+ motorical_wait_for_outcome: ['read:analytics'],
90
+ motorical_get_send_status: ['read:analytics'],
91
+ motorical_list_motor_blocks: ['read:analytics'],
92
+ motorical_get_overview: ['read:analytics'],
93
+ motorical_get_daily_summary: ['read:analytics'],
94
+ motorical_get_metrics: ['read:analytics'],
95
+ motorical_get_deliverability: ['read:analytics'],
96
+ motorical_get_reputation: ['read:analytics'],
97
+ motorical_get_anomalies: ['read:analytics'],
98
+ motorical_get_providers: ['read:analytics'],
99
+ motorical_get_error_codes: ['read:analytics'],
100
+ motorical_get_rate_limits: ['read:analytics'],
101
+ motorical_get_account_rate_limits: ['read:analytics'],
102
+ motorical_get_onboarding_state: ['read:analytics'],
103
+ motorical_get_logs: ['read:analytics'],
104
+ motorical_get_message_by_smtp_id: ['read:analytics'],
105
+ motorical_get_config: ['manage:domains'],
106
+ motorical_get_domain_health: ['manage:domains'],
107
+ motorical_domain_list: ['read:domains'],
108
+ motorical_domain_add: ['manage:domains'],
109
+ motorical_domain_verify: ['manage:domains'],
110
+ motorical_domain_check_dns: ['manage:domains'],
111
+ motorical_sandbox_status: ['manage:sandbox'],
112
+ motorical_sandbox_provision: ['manage:sandbox'],
113
+ motorical_sandbox_convert: ['manage:sandbox'],
114
+ motorical_sandbox_allowlist_request: ['manage:sandbox'],
115
+ motorical_sandbox_allowlist_confirm: ['manage:sandbox'],
116
+ motorical_webhook_list: ['read:webhooks'],
117
+ motorical_webhook_create: ['manage:webhooks'],
118
+ motorical_webhook_update: ['manage:webhooks'],
119
+ motorical_webhook_delete: ['manage:webhooks'],
120
+ motorical_webhook_test: ['manage:webhooks'],
121
+ motorical_webhook_get_deliveries: ['read:webhooks'],
122
+ motorical_webhook_get_stats: ['read:webhooks'],
123
+ };
124
+
125
+ /** `|| []`: a tool with no entry (signup's own tool) contributes no scopes rather than throwing. */
126
+ function scopesForTools(tools) {
127
+ return [...new Set(tools.flatMap((t) => TOOL_SCOPES[t] || []))];
128
+ }
129
+
130
+ const SERVER_SLUGS = {
131
+ main: 'motorical',
132
+ transactional: 'motorical_transactional',
133
+ analytics: 'motorical_analytics',
134
+ domains: 'motorical_domains',
135
+ sandbox: 'motorical_sandbox',
136
+ webhooks: 'motorical_webhooks',
137
+ signup: 'signup',
138
+ };
139
+
140
+ /** Every authenticated server, keyed by its canonical URI. signup is excluded — public, no token, never grantable. */
141
+ const RESOURCE_SCOPES = Object.fromEntries(
142
+ Object.keys(SERVER_TOOLS)
143
+ .filter((key) => key !== 'signup')
144
+ .map((key) => [`${MCP_HOST}/v1/${SERVER_SLUGS[key]}/mcp`, scopesForTools(SERVER_TOOLS[key])])
145
+ );
146
+
147
+ module.exports = {
148
+ MCP_HOST, SERVER_TOOLS, TOOL_SCOPES, scopesForTools, RESOURCE_SCOPES,
149
+ };