@firenet-designs/fnd-cli 2.4.0 → 2.7.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.
Files changed (60) hide show
  1. package/README.md +194 -57
  2. package/bin/dev.js +1 -1
  3. package/dist/commands/alt-text.d.ts +105 -0
  4. package/dist/commands/alt-text.js +616 -0
  5. package/dist/commands/backfill-project.js +1 -1
  6. package/dist/commands/create-project.js +48 -5
  7. package/dist/commands/workspace/index.d.ts +19 -2
  8. package/dist/commands/workspace/index.js +171 -56
  9. package/dist/lib/alt-text.d.ts +87 -0
  10. package/dist/lib/alt-text.js +196 -0
  11. package/dist/lib/image-filter.d.ts +43 -0
  12. package/dist/lib/image-filter.js +71 -0
  13. package/dist/lib/mcp/bracket-args.d.ts +37 -0
  14. package/dist/lib/mcp/bracket-args.js +65 -0
  15. package/dist/lib/mcp/define-tool.d.ts +52 -0
  16. package/dist/lib/mcp/define-tool.js +2 -0
  17. package/dist/lib/mcp/registry.d.ts +38 -0
  18. package/dist/lib/mcp/registry.js +98 -0
  19. package/dist/lib/mcp/server.d.ts +66 -0
  20. package/dist/lib/mcp/server.js +176 -0
  21. package/dist/lib/mcp/tools/shopify-common.d.ts +139 -0
  22. package/dist/lib/mcp/tools/shopify-common.js +167 -0
  23. package/dist/lib/mcp/tools/shopify-execute.d.ts +2 -0
  24. package/dist/lib/mcp/tools/shopify-execute.js +105 -0
  25. package/dist/lib/mcp/tools/shopify-file-delete.d.ts +2 -0
  26. package/dist/lib/mcp/tools/shopify-file-delete.js +49 -0
  27. package/dist/lib/mcp/tools/shopify-file-replace.d.ts +2 -0
  28. package/dist/lib/mcp/tools/shopify-file-replace.js +79 -0
  29. package/dist/lib/mcp/tools/shopify-file-search.d.ts +2 -0
  30. package/dist/lib/mcp/tools/shopify-file-search.js +199 -0
  31. package/dist/lib/mcp/tools/shopify-file-upload.d.ts +2 -0
  32. package/dist/lib/mcp/tools/shopify-file-upload.js +76 -0
  33. package/dist/lib/shopify/graphql/AccessScopes.graphql +7 -0
  34. package/dist/lib/shopify/graphql/CurrentBulkOperation.graphql +8 -0
  35. package/dist/lib/shopify/graphql/FileCreate.graphql +25 -0
  36. package/dist/lib/shopify/graphql/FileDelete.graphql +11 -0
  37. package/dist/lib/shopify/graphql/FileReplace.graphql +26 -0
  38. package/dist/lib/shopify/graphql/FileStatus.graphql +19 -0
  39. package/dist/lib/shopify/graphql/FilesBulkQuery.graphql +27 -0
  40. package/dist/lib/shopify/graphql/ProductsBulkQuery.graphql +27 -0
  41. package/dist/lib/shopify/graphql/SearchFiles.graphql +36 -0
  42. package/dist/lib/shopify/graphql/StagedUploadsCreate.graphql +20 -0
  43. package/dist/lib/shopify/graphql/StartBulkQuery.graphql +16 -0
  44. package/dist/lib/shopify/graphql/UpdateFileAlt.graphql +9 -0
  45. package/dist/lib/shopify/shopify.d.ts +228 -0
  46. package/dist/lib/shopify/shopify.js +662 -0
  47. package/dist/lib/webflow.d.ts +80 -0
  48. package/dist/lib/webflow.js +122 -0
  49. package/dist/lib/workspace.d.ts +29 -10
  50. package/dist/lib/workspace.js +74 -39
  51. package/oclif.manifest.json +162 -78
  52. package/package.json +21 -10
  53. package/dist/commands/workspace/cleanup.d.ts +0 -14
  54. package/dist/commands/workspace/cleanup.js +0 -84
  55. package/dist/hooks/init/check-for-updates.d.ts +0 -3
  56. package/dist/hooks/init/check-for-updates.js +0 -15
  57. package/dist/lib/kv-flag.d.ts +0 -15
  58. package/dist/lib/kv-flag.js +0 -75
  59. package/dist/lib/rpc.d.ts +0 -69
  60. package/dist/lib/rpc.js +0 -313
@@ -0,0 +1,66 @@
1
+ /**
2
+ * The workspace tools MCP server.
3
+ *
4
+ * Topology: this machine (the one that ran `fnd workspace`) runs a tiny MCP
5
+ * server — Streamable HTTP transport, implemented on node:http with no
6
+ * dependencies — bound to 127.0.0.1:<local>. The workspace's `ssh -R` reverse
7
+ * tunnel exposes it on the REMOTE at 127.0.0.1:<remote>, where the `claude` CLI
8
+ * registers it as an HTTP MCP server. When the AI on the remote calls a tool,
9
+ * the tool's handler runs HERE, on the calling machine.
10
+ *
11
+ * The server binds loopback only; the sole way in from outside is the reverse
12
+ * tunnel, which lives exactly as long as the ssh session. What tools it serves
13
+ * is entirely up to the caller — see registry.ts / defineTool. The server itself
14
+ * knows nothing about any particular tool; it just dispatches tools/call to the
15
+ * matching spec's handler.
16
+ *
17
+ * STATELESS Streamable HTTP (MCP 2026-07-28): the server keeps no session. It
18
+ * never issues an `Mcp-Session-Id`, so it never requires one back, and every POST
19
+ * is handled independently — each request carries its own protocol version and
20
+ * capabilities in `_meta`, so a client may call any method (e.g. `tools/call`)
21
+ * directly. Discovery is the stateless `server/discover` request (there is no
22
+ * `initialize` handshake in the current spec, though we still answer the legacy
23
+ * one for older clients). Each POST gets a single `application/json` JSON-RPC
24
+ * reply; we open no server→client SSE stream, so GET (and any non-POST) gets 405.
25
+ * This suits a short-lived, single-client, loopback tunnel where session
26
+ * bookkeeping would only add fragility across a reconnect.
27
+ */
28
+ /** The result a tool handler hands back: a text payload and whether it's an error. */
29
+ export interface McpToolResult {
30
+ isError?: boolean;
31
+ text: string;
32
+ }
33
+ /**
34
+ * One tool the server advertises and can run. `inputSchema` is a JSON Schema
35
+ * object shown to the model; `handler` receives the parsed `arguments` and
36
+ * returns the text block the model sees. A thrown handler is turned into an
37
+ * isError result, so a handler may either return `{isError:true}` or throw.
38
+ */
39
+ export interface McpToolSpec {
40
+ description: string;
41
+ handler: (args: Record<string, unknown>) => McpToolResult | Promise<McpToolResult>;
42
+ inputSchema: object;
43
+ name: string;
44
+ /**
45
+ * When true, advertise the tool with `_meta["anthropic/requiresUserInteraction"]`
46
+ * so the remote `claude` prompts for confirmation on EVERY call — even under
47
+ * broad auto-accept or bypass permissions (Claude Code ≥ 2.1.199; older clients
48
+ * ignore the hint and the tool just runs, so it degrades to today's behaviour).
49
+ * Used for destructive tools opted in with the `[ask]` selection arg.
50
+ */
51
+ requiresUserInteraction?: boolean;
52
+ }
53
+ /** Handle for a running MCP server. */
54
+ export interface McpServer {
55
+ close: () => Promise<void>;
56
+ /** The 127.0.0.1 port the server bound to (OS-assigned when not requested). */
57
+ port: number;
58
+ }
59
+ /**
60
+ * Start the MCP server on 127.0.0.1, serving `tools`. With no `port` the OS
61
+ * assigns a free one, returned on the handle so the caller can point the reverse
62
+ * tunnel at it. Resolves once the port is bound; rejects if binding fails.
63
+ */
64
+ export declare const startMcpServer: (tools: McpToolSpec[], opts?: {
65
+ port?: number;
66
+ }) => Promise<McpServer>;
@@ -0,0 +1,176 @@
1
+ import { createServer } from 'node:http';
2
+ const MAX_BODY_BYTES = 4 * 1024 * 1024;
3
+ const SERVER_INFO = { name: 'fnd-tools', version: '1.0.0' };
4
+ /**
5
+ * The MCP revisions this server accepts. Current-spec clients (2026-07-28+) speak
6
+ * the stateless `server/discover` model; the older revisions are here so a client
7
+ * still using the legacy `initialize` handshake also connects. `LATEST` is what
8
+ * discovery advertises as newest; `LEGACY` is the newest an `initialize`-era
9
+ * client understands, so we never answer that handshake with a discovery-only
10
+ * version it can't parse.
11
+ */
12
+ const SUPPORTED_PROTOCOL_VERSIONS = ['2024-11-05', '2025-03-26', '2025-06-18', '2026-07-28'];
13
+ const LEGACY_PROTOCOL_VERSION = '2025-06-18';
14
+ /** How long a client may cache discovery / the tool list (freshness hints, ms). */
15
+ const DISCOVER_TTL_MS = 60 * 60 * 1000;
16
+ const TOOLS_LIST_TTL_MS = 5 * 60 * 1000;
17
+ const rpcError = (id, code, message) => ({
18
+ error: { code, message },
19
+ id,
20
+ jsonrpc: '2.0',
21
+ });
22
+ const rpcResult = (id, result) => ({ id, jsonrpc: '2.0', result });
23
+ /** The tools/list entry for a spec — the handler is server-side only. */
24
+ const advertise = (spec) => ({
25
+ description: spec.description,
26
+ inputSchema: spec.inputSchema,
27
+ // Force a per-call confirmation prompt on the client for a tool opted into it.
28
+ // The key is the one Claude Code reads (`anthropic/requiresUserInteraction`);
29
+ // other MCP clients that don't know it simply ignore the _meta.
30
+ ...(spec.requiresUserInteraction ? { _meta: { 'anthropic/requiresUserInteraction': true } } : {}),
31
+ name: spec.name,
32
+ });
33
+ /** Turn a tool handler's result into the JSON-RPC result body for `tools/call`. */
34
+ const callResult = (content, isError) => ({ content, isError, resultType: 'complete' });
35
+ /**
36
+ * Handle one JSON-RPC message. Returns the response object for requests, or
37
+ * undefined for notifications (which get no response body).
38
+ *
39
+ * This follows the stateless data-layer model (MCP 2026-07-28): there is no
40
+ * session and no `initialize` round-trip — a client discovers the server with
41
+ * `server/discover` (optional; every request already carries its own protocol
42
+ * version and capabilities in `_meta`) and may call any method directly. The
43
+ * legacy `initialize`/`ping` methods are still answered so an older client
44
+ * connects too; unknown `_meta` on any request is simply ignored.
45
+ */
46
+ const handleRpcMessage = async (msg, tools) => {
47
+ const isRequest = msg.id !== undefined && msg.id !== null;
48
+ if (msg.jsonrpc !== '2.0' || typeof msg.method !== 'string') {
49
+ return isRequest ? rpcError(msg.id, -32_600, 'Invalid request') : undefined;
50
+ }
51
+ if (!isRequest)
52
+ return undefined; // notifications (e.g. notifications/initialized) need no reply
53
+ const id = msg.id;
54
+ switch (msg.method) {
55
+ case 'initialize': {
56
+ // Legacy handshake (pre-2026-07-28). Echo the requested revision when we
57
+ // support it, else offer the newest an initialize-era client understands.
58
+ const requested = msg.params?.protocolVersion;
59
+ const protocolVersion = typeof requested === 'string' && SUPPORTED_PROTOCOL_VERSIONS.includes(requested)
60
+ ? requested
61
+ : LEGACY_PROTOCOL_VERSION;
62
+ return rpcResult(id, { capabilities: { tools: {} }, protocolVersion, serverInfo: SERVER_INFO });
63
+ }
64
+ case 'ping': {
65
+ return rpcResult(id, {});
66
+ }
67
+ case 'server/discover': {
68
+ // The current stateless discovery request: advertise identity, capabilities
69
+ // and supported versions in one cacheable reply. `tools: {}` (no
70
+ // listChanged) because our tool set is fixed for the session — the client
71
+ // never needs to open a subscriptions/listen stream for tool changes.
72
+ return rpcResult(id, {
73
+ _meta: { 'io.modelcontextprotocol/serverInfo': SERVER_INFO },
74
+ cacheScope: 'public',
75
+ capabilities: { tools: {} },
76
+ resultType: 'complete',
77
+ supportedVersions: SUPPORTED_PROTOCOL_VERSIONS,
78
+ ttlMs: DISCOVER_TTL_MS,
79
+ });
80
+ }
81
+ case 'tools/call': {
82
+ const params = msg.params ?? {};
83
+ const tool = tools.find((t) => t.name === params.name);
84
+ if (!tool)
85
+ return rpcError(id, -32_602, `Unknown tool: ${String(params.name)}`);
86
+ const args = (params.arguments ?? {});
87
+ try {
88
+ const result = await tool.handler(args);
89
+ return rpcResult(id, callResult([{ text: result.text, type: 'text' }], result.isError ?? false));
90
+ }
91
+ catch (error) {
92
+ // A thrown handler is a tool-level error, not a protocol one: report it
93
+ // in-band so the model sees the reason instead of a dead connection.
94
+ return rpcResult(id, callResult([{ text: `Tool "${tool.name}" failed: ${error.message}`, type: 'text' }], true));
95
+ }
96
+ }
97
+ case 'tools/list': {
98
+ return rpcResult(id, {
99
+ cacheScope: 'public',
100
+ resultType: 'complete',
101
+ tools: tools.map((t) => advertise(t)),
102
+ ttlMs: TOOLS_LIST_TTL_MS,
103
+ });
104
+ }
105
+ default: {
106
+ return rpcError(id, -32_601, `Method not found: ${msg.method}`);
107
+ }
108
+ }
109
+ };
110
+ /** Read a request body, rejecting when it exceeds the size cap. */
111
+ const readBody = (req) => new Promise((resolve, reject) => {
112
+ let size = 0;
113
+ const chunks = [];
114
+ req.on('data', (chunk) => {
115
+ size += chunk.length;
116
+ if (size > MAX_BODY_BYTES) {
117
+ reject(new Error('request body too large'));
118
+ req.destroy();
119
+ return;
120
+ }
121
+ chunks.push(chunk);
122
+ });
123
+ req.once('end', () => resolve(Buffer.concat(chunks).toString()));
124
+ req.once('error', reject);
125
+ });
126
+ const handleHttpRequest = async (req, res, tools) => {
127
+ // Streamable HTTP: clients POST JSON-RPC messages. We don't offer a
128
+ // server-initiated SSE stream, so GET (and anything else) gets 405.
129
+ if (req.method !== 'POST') {
130
+ res.writeHead(405, { allow: 'POST' }).end();
131
+ return;
132
+ }
133
+ let parsed;
134
+ try {
135
+ parsed = JSON.parse(await readBody(req));
136
+ }
137
+ catch {
138
+ res.writeHead(400, { 'content-type': 'application/json' }).end(JSON.stringify(rpcError(null, -32_700, 'Parse error')));
139
+ return;
140
+ }
141
+ const messages = (Array.isArray(parsed) ? parsed : [parsed]);
142
+ const responses = (await Promise.all(messages.map((m) => handleRpcMessage(m, tools)))).filter((r) => r !== undefined);
143
+ // A body of nothing but notifications gets 202 Accepted with no content.
144
+ if (responses.length === 0) {
145
+ res.writeHead(202).end();
146
+ return;
147
+ }
148
+ const payload = Array.isArray(parsed) ? responses : responses[0];
149
+ res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify(payload));
150
+ };
151
+ /**
152
+ * Start the MCP server on 127.0.0.1, serving `tools`. With no `port` the OS
153
+ * assigns a free one, returned on the handle so the caller can point the reverse
154
+ * tunnel at it. Resolves once the port is bound; rejects if binding fails.
155
+ */
156
+ export const startMcpServer = (tools, opts = {}) => new Promise((resolve, reject) => {
157
+ const server = createServer((req, res) => {
158
+ handleHttpRequest(req, res, tools).catch(() => {
159
+ if (!res.headersSent)
160
+ res.writeHead(500);
161
+ res.end();
162
+ });
163
+ });
164
+ server.once('error', reject);
165
+ server.listen(opts.port ?? 0, '127.0.0.1', () => {
166
+ const address = server.address();
167
+ const port = typeof address === 'object' && address ? address.port : (opts.port ?? 0);
168
+ resolve({
169
+ close: () => new Promise((done) => {
170
+ server.closeAllConnections();
171
+ server.close(() => done());
172
+ }),
173
+ port,
174
+ });
175
+ });
176
+ });
@@ -0,0 +1,139 @@
1
+ import type { ParseContext } from '#lib/mcp/define-tool.js';
2
+ /**
3
+ * Shared plumbing for the Shopify `--with-tool` tools (upload / replace / delete /
4
+ * search / execute).
5
+ *
6
+ * They all act on one store's data through the user's own Shopify CLI
7
+ * (`shopify store execute` / `shopify store auth`), which owns the OAuth session —
8
+ * we never hold a token. The store is NOT a bracketed argument: it comes from the
9
+ * workspace command's `--site-id` flag, so every Shopify tool in a run targets
10
+ * the same store and the remote AI cannot pick another (a deliberate safety
11
+ * boundary). The operations run on THIS machine (the caller), reading files from
12
+ * the local side of the sync.
13
+ *
14
+ * Scopes are reconciled ONCE across all the selected tools (reconcileShopifyScopes):
15
+ * the store is granted the union of what the tools need. The file tools
16
+ * (upload/replace/delete/search) are bounded by hardcoded gates — the delete tool
17
+ * can only ever call `fileDelete`, never touch a customer — so an over-granted
18
+ * store is harmless to them and their extra scopes are never stripped. The
19
+ * `shopify-execute` tool is different: it runs arbitrary Admin GraphQL, so its
20
+ * blast radius IS the granted scope set. When it's in the run we trim the store to
21
+ * exactly the union (revoking anything extra), confining the AI to least privilege.
22
+ */
23
+ /** The config a Shopify tool's selection produces; shared by all of them. */
24
+ export interface ShopifyToolConfig {
25
+ /**
26
+ * The `[ask]` selection opts the tool into a per-call confirmation prompt on
27
+ * the remote (via the MCP server's `requiresUserInteraction` hint), so a
28
+ * destructive tool always asks even under auto-accept.
29
+ */
30
+ ask: boolean;
31
+ /** Resolved shopify CLI path, filled in by preflight/reconcile once we know it exists. */
32
+ bin?: string;
33
+ /**
34
+ * shopify-execute[scopes=all]: grant a broad Admin scope set and never revoke —
35
+ * the AI gets wide access on purpose. Ignored (false) for the bounded file tools.
36
+ */
37
+ grantAll: boolean;
38
+ /**
39
+ * Whether this tool's presence should trim the store to EXACTLY the union of
40
+ * tool scopes, revoking anything extra. Only `shopify-execute` sets this (its
41
+ * reach is the scope set); the file tools leave it false, so selecting one never
42
+ * strips a store's other grants.
43
+ */
44
+ restrictScopes: boolean;
45
+ /** The Admin scopes this tool needs; unioned across all selected Shopify tools when reconciling. */
46
+ scopes: readonly string[];
47
+ /** The normalized `*.myshopify.com` domain, from `--site-id`. */
48
+ store: string;
49
+ }
50
+ /** The `--site-id` store, normalized; throws (the registry prefixes the tool name) when it's absent. */
51
+ export declare const requireStore: (context: ParseContext) => string;
52
+ /**
53
+ * Build a `parse` for a fixed-scope Shopify tool (the file tools): the store
54
+ * comes from `--site-id`, the only bracketed option is the optional `[ask]` gate,
55
+ * and the tool's scopes are constant (write_files / read_files). shopify-execute
56
+ * has its own parse — its scopes come from the `scopes=` option — so it doesn't
57
+ * use this.
58
+ */
59
+ export declare const makeShopifyParse: (scopes: readonly string[]) => (arg: string | undefined, context: ParseContext) => ShopifyToolConfig;
60
+ /** `parse` for the file-writing tools (upload/replace/delete): fixed `write_files`. */
61
+ export declare const shopifyWriteFileParse: (arg: string | undefined, context: ParseContext) => ShopifyToolConfig;
62
+ /** `parse` for the read-only search tool: fixed `read_files`. */
63
+ export declare const shopifyReadFileParse: (arg: string | undefined, context: ParseContext) => ShopifyToolConfig;
64
+ /**
65
+ * A Shopify tool's preflight: just resolve the CLI, so `build` has a path and a
66
+ * missing CLI aborts before we connect (with a friendly message, like the
67
+ * --devtools port check). Authentication and scope granting are NOT done here —
68
+ * they happen once across all the tools in reconcileShopifyScopes, so the store
69
+ * is authed with the union rather than re-prompting per tool.
70
+ */
71
+ export declare const shopifyPreflight: (config: ShopifyToolConfig) => void;
72
+ /** The CLI path build() needs, asserting preflight/reconcile already resolved it. */
73
+ export declare const requireBin: (config: ShopifyToolConfig) => string;
74
+ /**
75
+ * A broad Admin scope set for `shopify-execute[scopes=all]`. It is deliberately
76
+ * curated (the common read/write resources) rather than literally every scope
77
+ * Shopify defines — that list is version-specific and unbounded — so "all" means
78
+ * "wide access to the usual resources". Extend it if a run needs a resource
79
+ * that's missing; a caller who needs an exact set can always list it explicitly.
80
+ */
81
+ export declare const ALL_ADMIN_SCOPES: readonly ["read_products", "write_products", "read_orders", "write_orders", "read_draft_orders", "write_draft_orders", "read_customers", "write_customers", "read_files", "write_files", "read_content", "write_content", "read_themes", "write_themes", "read_inventory", "write_inventory", "read_fulfillments", "write_fulfillments", "read_shipping", "write_shipping", "read_discounts", "write_discounts", "read_price_rules", "write_price_rules", "read_marketing_events", "write_marketing_events", "read_translations", "write_translations", "read_metaobjects", "write_metaobjects", "read_metaobject_definitions", "write_metaobject_definitions", "read_publications", "write_publications", "read_locations"];
82
+ /** What one store's reconciliation should do, computed purely from its current grant. */
83
+ export interface ScopePlan {
84
+ /** Granted scopes to strip (always empty unless restricting) — reported, never silent. */
85
+ extra: string[];
86
+ /** Needed scopes not yet granted. */
87
+ missing: string[];
88
+ /** Whether a `shopify store auth` call is required (unauthenticated, or a grant/revoke to apply). */
89
+ reauth: boolean;
90
+ /** Whether extras are being revoked (restrict mode) — drives the log wording. */
91
+ restrict: boolean;
92
+ /** The exact scope set the installation should end up granting. */
93
+ target: string[];
94
+ }
95
+ /** How the selected Shopify tools want their store scoped, folded across all of them. */
96
+ export interface ScopeNeed {
97
+ /** `scopes=all`: grant the broad curated set and never revoke. */
98
+ grantAll: boolean;
99
+ /** Trim to exactly the union (revoke extras) — set by shopify-execute with an explicit list. */
100
+ restrict: boolean;
101
+ /** Union of every selected Shopify tool's scopes. */
102
+ toolScopes: readonly string[];
103
+ }
104
+ /**
105
+ * Decide, purely, what a store's scopes should become — the heart of the
106
+ * reconciliation, split out so it's testable without the Shopify CLI.
107
+ *
108
+ * The target the installation should end up granting is the union of the tools'
109
+ * scopes (plus ALL_ADMIN_SCOPES under `grantAll`). In `restrict` mode the grant
110
+ * becomes EXACTLY that union, so anything extra is revoked (a `read_x` already
111
+ * covered by a needed `write_x` isn't counted as extra — dropping it would churn
112
+ * the auth for nothing). Otherwise we only ever ADD, so the store's other grants
113
+ * are left untouched. A re-auth is needed when unauthenticated, or when there's
114
+ * anything to grant or revoke.
115
+ */
116
+ export declare const planShopifyScopes: (granted: string[], authenticated: boolean, need: ScopeNeed) => ScopePlan;
117
+ /**
118
+ * Reconcile the store's granted scopes with what the selected Shopify tools
119
+ * actually need — once, before connecting — so any interactive `shopify store
120
+ * auth` happens while the user is watching.
121
+ *
122
+ * Grant the union the tools need; REVOKE anything extra ONLY when `shopify-execute`
123
+ * is in the run with an explicit scope list — its reach is the granted scope set,
124
+ * so we trim it to least privilege. The file tools never trigger revocation:
125
+ * they're bounded by hardcoded gates (the delete tool can only delete a file), so
126
+ * an over-scoped store can't be abused through them, and stripping scopes they
127
+ * don't use would just churn the auth of the user's other Shopify work.
128
+ *
129
+ * `shopify store auth --scopes <set>` re-runs OAuth, so the installation ends up
130
+ * granting exactly <set>: that both adds the missing scopes and drops any not in
131
+ * <set>. That re-auth is how we revoke — there is no separate revoke command.
132
+ * (The decision is in planShopifyScopes; this just runs the CLI around it.)
133
+ *
134
+ * `selections` is the command's tool list (structurally `{ config }`); non-Shopify
135
+ * and non-selected tools are ignored, so a run with no Shopify tool is a no-op.
136
+ */
137
+ export declare const reconcileShopifyScopes: (selections: readonly {
138
+ config: unknown;
139
+ }[], log: (message: string) => void) => void;
@@ -0,0 +1,167 @@
1
+ import { parseBracketArgs } from '#lib/mcp/bracket-args.js';
2
+ import { findShopifyBin } from '#lib/scaffold.js';
3
+ import { authenticate, FILE_READ_SCOPES, FILE_WRITE_SCOPES, getInstalledScopes, missingScopes, normalizeStore } from '#lib/shopify/shopify.js';
4
+ /** The `--site-id` store, normalized; throws (the registry prefixes the tool name) when it's absent. */
5
+ export const requireStore = (context) => {
6
+ const siteId = context.siteId?.trim();
7
+ if (!siteId) {
8
+ throw new Error('requires --site-id (the Shopify store, e.g. mystore or mystore.myshopify.com).');
9
+ }
10
+ return normalizeStore(siteId);
11
+ };
12
+ /**
13
+ * Build a `parse` for a fixed-scope Shopify tool (the file tools): the store
14
+ * comes from `--site-id`, the only bracketed option is the optional `[ask]` gate,
15
+ * and the tool's scopes are constant (write_files / read_files). shopify-execute
16
+ * has its own parse — its scopes come from the `scopes=` option — so it doesn't
17
+ * use this.
18
+ */
19
+ export const makeShopifyParse = (scopes) => (arg, context) => {
20
+ const store = requireStore(context);
21
+ const parsed = parseBracketArgs(arg, { flags: ['ask'] });
22
+ return { ask: parsed.flags.has('ask'), grantAll: false, restrictScopes: false, scopes, store };
23
+ };
24
+ /** `parse` for the file-writing tools (upload/replace/delete): fixed `write_files`. */
25
+ export const shopifyWriteFileParse = makeShopifyParse(FILE_WRITE_SCOPES);
26
+ /** `parse` for the read-only search tool: fixed `read_files`. */
27
+ export const shopifyReadFileParse = makeShopifyParse(FILE_READ_SCOPES);
28
+ /**
29
+ * A Shopify tool's preflight: just resolve the CLI, so `build` has a path and a
30
+ * missing CLI aborts before we connect (with a friendly message, like the
31
+ * --devtools port check). Authentication and scope granting are NOT done here —
32
+ * they happen once across all the tools in reconcileShopifyScopes, so the store
33
+ * is authed with the union rather than re-prompting per tool.
34
+ */
35
+ export const shopifyPreflight = (config) => {
36
+ const bin = findShopifyBin();
37
+ if (!bin) {
38
+ throw new Error('Shopify CLI not found on PATH (or any nvm node). Install it (npm i -g @shopify/cli) before using this tool.');
39
+ }
40
+ config.bin = bin;
41
+ };
42
+ /** The CLI path build() needs, asserting preflight/reconcile already resolved it. */
43
+ export const requireBin = (config) => {
44
+ const bin = config.bin ?? findShopifyBin();
45
+ if (!bin)
46
+ throw new Error('Shopify CLI not found; cannot build the Shopify tool.');
47
+ return bin;
48
+ };
49
+ /**
50
+ * A broad Admin scope set for `shopify-execute[scopes=all]`. It is deliberately
51
+ * curated (the common read/write resources) rather than literally every scope
52
+ * Shopify defines — that list is version-specific and unbounded — so "all" means
53
+ * "wide access to the usual resources". Extend it if a run needs a resource
54
+ * that's missing; a caller who needs an exact set can always list it explicitly.
55
+ */
56
+ export const ALL_ADMIN_SCOPES = [
57
+ 'read_products', 'write_products',
58
+ 'read_orders', 'write_orders',
59
+ 'read_draft_orders', 'write_draft_orders',
60
+ 'read_customers', 'write_customers',
61
+ 'read_files', 'write_files',
62
+ 'read_content', 'write_content',
63
+ 'read_themes', 'write_themes',
64
+ 'read_inventory', 'write_inventory',
65
+ 'read_fulfillments', 'write_fulfillments',
66
+ 'read_shipping', 'write_shipping',
67
+ 'read_discounts', 'write_discounts',
68
+ 'read_price_rules', 'write_price_rules',
69
+ 'read_marketing_events', 'write_marketing_events',
70
+ 'read_translations', 'write_translations',
71
+ 'read_metaobjects', 'write_metaobjects',
72
+ 'read_metaobject_definitions', 'write_metaobject_definitions',
73
+ 'read_publications', 'write_publications',
74
+ 'read_locations',
75
+ ];
76
+ const unique = (scopes) => [...new Set(scopes)];
77
+ /** A ShopifyToolConfig among the (type-erased) selection configs. */
78
+ const isShopifyConfig = (config) => typeof config === 'object' &&
79
+ config !== null &&
80
+ typeof config.store === 'string' &&
81
+ Array.isArray(config.scopes);
82
+ /**
83
+ * Whether a currently-granted scope is already covered by `needed`, so keeping it
84
+ * isn't "extra". `write_x` covers `read_x` (see missingScopes), so a granted
85
+ * `read_x` is redundant — not worth a re-auth to drop — when `write_x` is needed.
86
+ */
87
+ const coveredByNeeded = (scope, needed) => needed.includes(scope) || (scope.startsWith('read_') && needed.includes(scope.replace('read_', 'write_')));
88
+ /**
89
+ * Decide, purely, what a store's scopes should become — the heart of the
90
+ * reconciliation, split out so it's testable without the Shopify CLI.
91
+ *
92
+ * The target the installation should end up granting is the union of the tools'
93
+ * scopes (plus ALL_ADMIN_SCOPES under `grantAll`). In `restrict` mode the grant
94
+ * becomes EXACTLY that union, so anything extra is revoked (a `read_x` already
95
+ * covered by a needed `write_x` isn't counted as extra — dropping it would churn
96
+ * the auth for nothing). Otherwise we only ever ADD, so the store's other grants
97
+ * are left untouched. A re-auth is needed when unauthenticated, or when there's
98
+ * anything to grant or revoke.
99
+ */
100
+ export const planShopifyScopes = (granted, authenticated, need) => {
101
+ const needed = need.grantAll ? unique([...need.toolScopes, ...ALL_ADMIN_SCOPES]) : unique([...need.toolScopes]);
102
+ const target = need.restrict ? needed : unique([...granted, ...needed]);
103
+ const missing = missingScopes(granted, needed);
104
+ const extra = need.restrict ? granted.filter((scope) => !coveredByNeeded(scope, needed)) : [];
105
+ const reauth = !authenticated || missing.length > 0 || extra.length > 0;
106
+ return { extra, missing, reauth, restrict: need.restrict, target };
107
+ };
108
+ /**
109
+ * Reconcile the store's granted scopes with what the selected Shopify tools
110
+ * actually need — once, before connecting — so any interactive `shopify store
111
+ * auth` happens while the user is watching.
112
+ *
113
+ * Grant the union the tools need; REVOKE anything extra ONLY when `shopify-execute`
114
+ * is in the run with an explicit scope list — its reach is the granted scope set,
115
+ * so we trim it to least privilege. The file tools never trigger revocation:
116
+ * they're bounded by hardcoded gates (the delete tool can only delete a file), so
117
+ * an over-scoped store can't be abused through them, and stripping scopes they
118
+ * don't use would just churn the auth of the user's other Shopify work.
119
+ *
120
+ * `shopify store auth --scopes <set>` re-runs OAuth, so the installation ends up
121
+ * granting exactly <set>: that both adds the missing scopes and drops any not in
122
+ * <set>. That re-auth is how we revoke — there is no separate revoke command.
123
+ * (The decision is in planShopifyScopes; this just runs the CLI around it.)
124
+ *
125
+ * `selections` is the command's tool list (structurally `{ config }`); non-Shopify
126
+ * and non-selected tools are ignored, so a run with no Shopify tool is a no-op.
127
+ */
128
+ export const reconcileShopifyScopes = (selections, log) => {
129
+ const configs = selections.map((s) => s.config).filter((config) => isShopifyConfig(config));
130
+ if (configs.length === 0)
131
+ return;
132
+ const bin = findShopifyBin();
133
+ if (!bin) {
134
+ throw new Error('Shopify CLI not found on PATH (or any nvm node). Install it (npm i -g @shopify/cli) before using the Shopify tools.');
135
+ }
136
+ for (const config of configs)
137
+ config.bin = bin;
138
+ // One run targets one store (--site-id is shared), but group defensively in
139
+ // case that ever changes — each store reconciles independently.
140
+ for (const store of unique(configs.map((c) => c.store))) {
141
+ const forStore = configs.filter((c) => c.store === store);
142
+ const grantAll = forStore.some((c) => c.grantAll);
143
+ const need = {
144
+ grantAll,
145
+ // Revoke extras only when shopify-execute is confining itself to an explicit
146
+ // set; `scopes=all` deliberately opts out of trimming.
147
+ restrict: forStore.some((c) => c.restrictScopes) && !grantAll,
148
+ toolScopes: unique(forStore.flatMap((c) => [...c.scopes])),
149
+ };
150
+ log(`Reconciling Shopify access for ${store}…`);
151
+ const { authenticated, scopes: granted } = getInstalledScopes(bin, store);
152
+ const plan = planShopifyScopes(granted, authenticated, need);
153
+ if (!plan.reauth) {
154
+ log(` ${store}: scopes already ${plan.restrict ? 'exactly ' : ''}as needed (${plan.target.join(', ') || 'none'})`);
155
+ continue;
156
+ }
157
+ if (!authenticated)
158
+ log(` ${store}: not authenticated — opening the browser via shopify store auth`);
159
+ if (plan.missing.length > 0)
160
+ log(` ${store}: granting ${plan.missing.join(', ')}`);
161
+ if (plan.extra.length > 0)
162
+ log(` ${store}: revoking ${plan.extra.join(', ')} (not needed by the selected tools)`);
163
+ if (!authenticate(bin, store, plan.target)) {
164
+ throw new Error(`Could not authenticate ${store} with scopes ${plan.target.join(', ')}. Try it directly: shopify store auth --store ${store} --scopes ${plan.target.join(',')}`);
165
+ }
166
+ }
167
+ };
@@ -0,0 +1,2 @@
1
+ import { ShopifyToolConfig } from './shopify-common.js';
2
+ export declare const shopifyExecuteTool: import("#lib/mcp/define-tool.js").WorkspaceTool<ShopifyToolConfig>;
@@ -0,0 +1,105 @@
1
+ import { parseBracketArgs } from '#lib/mcp/bracket-args.js';
2
+ import { defineTool } from '#lib/mcp/define-tool.js';
3
+ import { runStoreExecute } from '#lib/shopify/shopify.js';
4
+ import { requireBin, requireStore, shopifyPreflight } from './shopify-common.js';
5
+ /**
6
+ * `--with-tool shopify-execute` — expose a `shopify_execute` MCP tool that runs an
7
+ * ARBITRARY Admin GraphQL operation against the store via the user's Shopify CLI.
8
+ * Store comes from `--site-id` (fixed — the AI can't change it).
9
+ *
10
+ * This is far more powerful than the file tools: it's raw Admin GraphQL, so what
11
+ * the AI can touch is exactly the store's granted scopes. That's why its bracket
12
+ * REQUIRES a `scopes=` option — `scopes=all` for broad access, or an explicit
13
+ * list like `scopes=read_products+write_orders` — and why the command reconciles
14
+ * the store to EXACTLY that set (revoking anything extra) before connecting, so
15
+ * the AI is confined to least privilege (see reconcileShopifyScopes). `ask` is
16
+ * optional and gates every call behind a confirmation prompt on the remote.
17
+ *
18
+ * shopify-execute[scopes=read_products+read_orders]
19
+ * shopify-execute[ask,scopes=all]
20
+ */
21
+ /** The MCP tool name the remote AI sees. (The registry name activates it; this is what it calls.) */
22
+ const MCP_TOOL_NAME = 'shopify_execute';
23
+ /** A trimmed non-empty string from an untyped arg, or undefined. */
24
+ const str = (value) => typeof value === 'string' && value.trim() !== '' ? value.trim() : undefined;
25
+ /** Split a `scopes=` value into individual scopes. `+` or whitespace separate them (comma is the option separator). */
26
+ const parseScopeList = (raw) => [...new Set(raw.split(/[\s+]+/).map((scope) => scope.trim().toLowerCase()).filter(Boolean))];
27
+ /** A Shopify access scope is `read_x` / `write_x`; reject anything else so a typo fails up front. */
28
+ const SCOPE_SHAPE = /^(read|write)_[a-z_]+$/;
29
+ /**
30
+ * Parse a `shopify-execute` selection. Unlike the file tools, its scopes aren't
31
+ * fixed — they come from the required `scopes=` option, which is what the reconcile
32
+ * step trims the store down to. `[ask]` stays optional.
33
+ */
34
+ const parseExecuteSelection = (arg, context) => {
35
+ const store = requireStore(context);
36
+ const parsed = parseBracketArgs(arg, { flags: ['ask'], values: ['scopes'] });
37
+ const ask = parsed.flags.has('ask');
38
+ const scopesRaw = parsed.values.get('scopes');
39
+ if (!scopesRaw) {
40
+ throw new Error('requires a scopes option, since it runs arbitrary Admin GraphQL: shopify-execute[scopes=all] for broad access, ' +
41
+ 'or an explicit list like shopify-execute[scopes=read_products+write_orders] (add ,ask to confirm every call).');
42
+ }
43
+ // `scopes=all` = grant a broad set and never revoke (see ALL_ADMIN_SCOPES).
44
+ if (scopesRaw.toLowerCase() === 'all') {
45
+ return { ask, grantAll: true, restrictScopes: true, scopes: [], store };
46
+ }
47
+ const scopes = parseScopeList(scopesRaw);
48
+ if (scopes.length === 0) {
49
+ throw new Error('the scopes option is empty; list one or more scopes, e.g. scopes=read_products+write_orders, or scopes=all.');
50
+ }
51
+ const bad = scopes.find((scope) => !SCOPE_SHAPE.test(scope));
52
+ if (bad) {
53
+ throw new Error(`"${bad}" is not a valid Admin scope (expected read_<resource> or write_<resource>, e.g. read_products).`);
54
+ }
55
+ return { ask, grantAll: false, restrictScopes: true, scopes, store };
56
+ };
57
+ export const shopifyExecuteTool = defineTool({
58
+ argHint: 'scopes=all|<list>[,ask]',
59
+ argRequired: true,
60
+ build(config) {
61
+ const { grantAll, scopes, store } = config;
62
+ // preflight/reconcile ran first and resolved the CLI; assert it for the handler.
63
+ const bin = requireBin(config);
64
+ const scopeDesc = grantAll ? 'a broad set of Admin scopes' : (scopes.join(', ') || 'none');
65
+ const spec = {
66
+ description: `Run an arbitrary Admin GraphQL operation against the Shopify store ${store} via \`shopify store execute\` ` +
67
+ `(the user's Shopify CLI, so it uses their existing login — no token passes through here). Provide \`query\` ` +
68
+ `(a GraphQL document with ONE operation) and optional \`variables\`. Reads run by default; a MUTATION must set ` +
69
+ `\`mutate: true\` (the CLI refuses mutations otherwise). This installation is limited to these scopes: ` +
70
+ `${scopeDesc} — an operation needing anything else fails with an access-denied error, so stay within them. ` +
71
+ `The store is fixed to ${store} and cannot be changed here. Returns the raw JSON response, including any ` +
72
+ `GraphQL \`errors\`.`,
73
+ handler(args) {
74
+ const query = str(args.query);
75
+ if (!query)
76
+ return { isError: true, text: 'The "query" argument must be a non-empty GraphQL document string.' };
77
+ const mutate = args.mutate === true;
78
+ const variables = args.variables && typeof args.variables === 'object' && !Array.isArray(args.variables)
79
+ ? args.variables
80
+ : undefined;
81
+ const result = runStoreExecute(bin, store, { mutate, query, variables });
82
+ return { isError: !result.ok, text: result.output };
83
+ },
84
+ inputSchema: {
85
+ properties: {
86
+ mutate: {
87
+ description: 'Set true to allow a mutation; `store execute` refuses mutations unless this is set. Leave false/omitted for read queries.',
88
+ type: 'boolean',
89
+ },
90
+ query: { description: 'The Admin GraphQL document to run (a single operation).', type: 'string' },
91
+ variables: { description: 'Optional variables object for the operation.', type: 'object' },
92
+ },
93
+ required: ['query'],
94
+ type: 'object',
95
+ },
96
+ name: MCP_TOOL_NAME,
97
+ // `[ask]` makes the remote prompt for confirmation before every execute.
98
+ requiresUserInteraction: config.ask,
99
+ };
100
+ return [spec];
101
+ },
102
+ name: 'shopify-execute',
103
+ parse: parseExecuteSelection,
104
+ preflight: shopifyPreflight,
105
+ });
@@ -0,0 +1,2 @@
1
+ import { ShopifyToolConfig } from './shopify-common.js';
2
+ export declare const shopifyFileDeleteTool: import("#lib/mcp/define-tool.js").WorkspaceTool<ShopifyToolConfig>;