@firenet-designs/fnd-cli 2.6.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.
- package/README.md +103 -63
- package/bin/dev.js +1 -1
- package/dist/commands/alt-text.d.ts +64 -15
- package/dist/commands/alt-text.js +277 -65
- package/dist/commands/backfill-project.js +1 -1
- package/dist/commands/create-project.js +1 -1
- package/dist/commands/workspace/index.d.ts +3 -2
- package/dist/commands/workspace/index.js +96 -49
- package/dist/lib/alt-text.d.ts +33 -2
- package/dist/lib/alt-text.js +56 -4
- package/dist/lib/mcp/bracket-args.d.ts +37 -0
- package/dist/lib/mcp/bracket-args.js +65 -0
- package/dist/lib/mcp/define-tool.d.ts +52 -0
- package/dist/lib/mcp/define-tool.js +2 -0
- package/dist/lib/mcp/registry.d.ts +38 -0
- package/dist/lib/mcp/registry.js +98 -0
- package/dist/lib/mcp/server.d.ts +66 -0
- package/dist/lib/mcp/server.js +176 -0
- package/dist/lib/mcp/tools/shopify-common.d.ts +139 -0
- package/dist/lib/mcp/tools/shopify-common.js +167 -0
- package/dist/lib/mcp/tools/shopify-execute.d.ts +2 -0
- package/dist/lib/mcp/tools/shopify-execute.js +105 -0
- package/dist/lib/mcp/tools/shopify-file-delete.d.ts +2 -0
- package/dist/lib/mcp/tools/shopify-file-delete.js +49 -0
- package/dist/lib/mcp/tools/shopify-file-replace.d.ts +2 -0
- package/dist/lib/mcp/tools/shopify-file-replace.js +79 -0
- package/dist/lib/mcp/tools/shopify-file-search.d.ts +2 -0
- package/dist/lib/mcp/tools/shopify-file-search.js +199 -0
- package/dist/lib/mcp/tools/shopify-file-upload.d.ts +2 -0
- package/dist/lib/mcp/tools/shopify-file-upload.js +76 -0
- package/dist/lib/shopify/graphql/AccessScopes.graphql +7 -0
- package/dist/lib/shopify/graphql/CurrentBulkOperation.graphql +8 -0
- package/dist/lib/shopify/graphql/FileCreate.graphql +25 -0
- package/dist/lib/shopify/graphql/FileDelete.graphql +11 -0
- package/dist/lib/shopify/graphql/FileReplace.graphql +26 -0
- package/dist/lib/shopify/graphql/FileStatus.graphql +19 -0
- package/dist/lib/shopify/graphql/FilesBulkQuery.graphql +27 -0
- package/dist/lib/shopify/graphql/ProductsBulkQuery.graphql +27 -0
- package/dist/lib/shopify/graphql/SearchFiles.graphql +36 -0
- package/dist/lib/shopify/graphql/StagedUploadsCreate.graphql +20 -0
- package/dist/lib/shopify/graphql/StartBulkQuery.graphql +16 -0
- package/dist/lib/shopify/graphql/UpdateFileAlt.graphql +9 -0
- package/dist/lib/shopify/shopify.d.ts +228 -0
- package/dist/lib/shopify/shopify.js +662 -0
- package/dist/lib/workspace.d.ts +19 -8
- package/dist/lib/workspace.js +13 -13
- package/oclif.manifest.json +48 -46
- package/package.json +17 -10
- package/dist/hooks/init/check-for-updates.d.ts +0 -3
- package/dist/hooks/init/check-for-updates.js +0 -15
- package/dist/lib/kv-flag.d.ts +0 -15
- package/dist/lib/kv-flag.js +0 -75
- package/dist/lib/rpc.d.ts +0 -69
- package/dist/lib/rpc.js +0 -313
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { shopifyExecuteTool } from './tools/shopify-execute.js';
|
|
2
|
+
import { shopifyFileDeleteTool } from './tools/shopify-file-delete.js';
|
|
3
|
+
import { shopifyFileReplaceTool } from './tools/shopify-file-replace.js';
|
|
4
|
+
import { shopifyFileSearchTool } from './tools/shopify-file-search.js';
|
|
5
|
+
import { shopifyFileUploadTool } from './tools/shopify-file-upload.js';
|
|
6
|
+
/**
|
|
7
|
+
* The `--with-tool` registry.
|
|
8
|
+
*
|
|
9
|
+
* `fnd workspace --with-tool <name>[<arg>]` exposes tools to Claude on the remote
|
|
10
|
+
* through the loopback MCP server (see server.ts). Each entry here is a
|
|
11
|
+
* WorkspaceTool built with defineTool (see define-tool.ts): it knows how to parse
|
|
12
|
+
* its bracketed argument, check its prerequisites BEFORE we connect (the same
|
|
13
|
+
* shape as the --devtools browser-port check), and build the MCP tool spec(s) the
|
|
14
|
+
* server actually serves. Adding a tool to the AI is: write one defineTool module
|
|
15
|
+
* and register it in TOOL_REGISTRY. Nothing else changes.
|
|
16
|
+
*/
|
|
17
|
+
/** Every tool `--with-tool` can name, keyed by tool name. */
|
|
18
|
+
export const TOOL_REGISTRY = {
|
|
19
|
+
[shopifyExecuteTool.name]: shopifyExecuteTool,
|
|
20
|
+
[shopifyFileDeleteTool.name]: shopifyFileDeleteTool,
|
|
21
|
+
[shopifyFileReplaceTool.name]: shopifyFileReplaceTool,
|
|
22
|
+
[shopifyFileSearchTool.name]: shopifyFileSearchTool,
|
|
23
|
+
[shopifyFileUploadTool.name]: shopifyFileUploadTool,
|
|
24
|
+
};
|
|
25
|
+
/** `name` or `name[arg]` -> its pieces. The arg may itself contain brackets. */
|
|
26
|
+
const parseSpec = (raw) => {
|
|
27
|
+
const match = raw.trim().match(/^([a-z0-9][a-z0-9-]*)(?:\[(.*)])?$/i);
|
|
28
|
+
if (!match) {
|
|
29
|
+
throw new Error(`--with-tool value "${raw}" is not valid; use <name> or <name>[<arg>], e.g. shopify-file-upload.`);
|
|
30
|
+
}
|
|
31
|
+
return { arg: match[2], name: match[1].toLowerCase() };
|
|
32
|
+
};
|
|
33
|
+
/** One-line usage listing the registered tools and their argument shape. */
|
|
34
|
+
export const withToolUsage = () => Object.values(TOOL_REGISTRY)
|
|
35
|
+
.map((t) => {
|
|
36
|
+
if (!t.argHint)
|
|
37
|
+
return t.name; // takes no bracketed [arg]
|
|
38
|
+
return t.argRequired ? `${t.name}[<${t.argHint}>]` : `${t.name}[[<${t.argHint}>]]`;
|
|
39
|
+
})
|
|
40
|
+
.join(', ');
|
|
41
|
+
/**
|
|
42
|
+
* Resolve each `--with-tool` value into a {tool, config} selection, validating
|
|
43
|
+
* the tool name and its argument up front. `context` carries command-level flags
|
|
44
|
+
* a tool's parse may depend on (e.g. `--site-id` for the Shopify tools). Throws
|
|
45
|
+
* on an unknown tool, a stray/missing argument, a parse failure (a required flag
|
|
46
|
+
* absent), or the same tool selected twice (its MCP tool names would collide on
|
|
47
|
+
* the server).
|
|
48
|
+
*/
|
|
49
|
+
export const resolveWorkspaceTools = (specs, context) => {
|
|
50
|
+
const selections = [];
|
|
51
|
+
const seen = new Set();
|
|
52
|
+
for (const raw of specs) {
|
|
53
|
+
const { arg, name } = parseSpec(raw);
|
|
54
|
+
const tool = TOOL_REGISTRY[name];
|
|
55
|
+
if (!tool) {
|
|
56
|
+
const known = Object.keys(TOOL_REGISTRY).join(', ');
|
|
57
|
+
throw new Error(`--with-tool has no "${name}" tool. Available: ${known}.`);
|
|
58
|
+
}
|
|
59
|
+
if (seen.has(name))
|
|
60
|
+
throw new Error(`--with-tool "${name}" was given more than once.`);
|
|
61
|
+
seen.add(name);
|
|
62
|
+
if (!tool.argHint && arg !== undefined) {
|
|
63
|
+
throw new Error(`--with-tool ${name} takes no argument; drop the [${arg}].`);
|
|
64
|
+
}
|
|
65
|
+
if (tool.argHint && tool.argRequired && (arg === undefined || arg.trim() === '')) {
|
|
66
|
+
throw new Error(`--with-tool ${name} needs an argument: ${name}[<${tool.argHint}>].`);
|
|
67
|
+
}
|
|
68
|
+
// parse validates command-level dependencies (e.g. the Shopify tools need
|
|
69
|
+
// --site-id); prefix its message with the tool name so the user knows which.
|
|
70
|
+
let config;
|
|
71
|
+
try {
|
|
72
|
+
config = tool.parse(arg, context);
|
|
73
|
+
}
|
|
74
|
+
catch (error) {
|
|
75
|
+
throw new Error(`--with-tool ${name} ${error.message}`);
|
|
76
|
+
}
|
|
77
|
+
selections.push({ config, raw, tool });
|
|
78
|
+
}
|
|
79
|
+
return selections;
|
|
80
|
+
};
|
|
81
|
+
/**
|
|
82
|
+
* The MCP tool specs for a set of selections. Rejects two tools that would
|
|
83
|
+
* advertise the same MCP tool name — the server dispatches by name, so a
|
|
84
|
+
* collision would make one unreachable.
|
|
85
|
+
*/
|
|
86
|
+
export const collectToolSpecs = (selections, runtime) => {
|
|
87
|
+
const specs = [];
|
|
88
|
+
const names = new Set();
|
|
89
|
+
for (const { config, tool } of selections) {
|
|
90
|
+
for (const spec of tool.build(config, runtime)) {
|
|
91
|
+
if (names.has(spec.name))
|
|
92
|
+
throw new Error(`Two --with-tool tools both define an MCP tool named "${spec.name}".`);
|
|
93
|
+
names.add(spec.name);
|
|
94
|
+
specs.push(spec);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return specs;
|
|
98
|
+
};
|
|
@@ -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
|
+
};
|