shopify-ops-mcp-client
shopify-ops-mcp-client
shopify-ops-mcp-client 是 Shopify Ops 远程 MCP 服务的轻量 stdio 桥接器。它不会在本地保存 Shopify Admin token、店铺凭证或权限策略,只把 MCP 协议消息通过 HTTPS 转发给中央 Streamable HTTP MCP 服务。
使用
SHOPIFY_OPS_MCP_URL=https://mcp.example.com/mcp \
SHOPIFY_OPS_MCP_TOKEN=replace-with-managed-client-token \
npx -y shopify-ops-mcp-client
SHOPIFY_OPS_MCP_URL 默认是本地开发地址 http://127.0.0.1:3000/mcp。生产环境必须使用 HTTPS;HTTP 只允许 loopback 地址。
不要通过命令行参数传 Token,因为参数可能出现在进程列表或 shell 历史中。每个 MCP 客户端或安装实例应使用服务器单独签发的 Token;服务器端的 Token binding 决定它能访问哪些店铺以及每个店铺的工具权限。
Codex 配置
[mcp_servers.shopify_ops_mcp]
command = "npx"
args = ["-y", "shopify-ops-mcp-client"]
startup_timeout_sec = 20
tool_timeout_sec = 120
[mcp_servers.shopify_ops_mcp.env]
SHOPIFY_OPS_MCP_URL = "https://mcp.example.com/mcp"
SHOPIFY_OPS_MCP_TOKEN = "replace-with-managed-client-token"
Claude Code 配置
{
"mcpServers": {
"shopify-ops-mcp": {
"command": "npx",
"args": ["-y", "shopify-ops-mcp-client"],
"env": {
"SHOPIFY_OPS_MCP_URL": "https://mcp.example.com/mcp",
"SHOPIFY_OPS_MCP_TOKEN": "replace-with-managed-client-token"
}
}
}
}
桥接器会原样转发客户端的 MCP initialize 请求,因此中央服务器可以记录原始 clientInfo。返回宿主时会保留服务端 instructions,并补充审批描述语言和工作流模板编写提示;其他协议消息仍原样转发。授权身份仍来自服务器签发的 Token;clientInfo 只用于诊断和追踪,不能授予权限。
工作流模板编写
当用户要求创建或修改工作流模板时,客户端初始化提示会要求 AI 每次先调用 workflow_get_template_authoring_rules,以服务端当前返回的 schema、限制和版本规则为准;随后可用 workflow_list_templates 和 workflow_get_template 查找已有模板作为参考。客户端不得自行补充 topic、行为步骤、输入/输出映射、条件、重试或其他默认行为,完整定义通过 workflow_publish_template 创建;相同 key 的再次发布会生成不可变新版本。
这是运行时指引,不是客户端内置并缓存的一份模板 schema,因此服务端规则升级后不需要重新发布客户端。如果所需工作流工具没有出现在工具列表中,客户端应说明缺少对应 MCP 权限,而不是猜测格式或绕过治理。
任务式审批
这个包是协议桥接器,不读取 Codex、Claude Code 等宿主里的原始用户消息,也无法从当前一次 tools/call 推断未来还会调用哪些工具。因此它不会猜测任务描述、自动拼接相邻调用,或在本地保存任务正文。
需要人工审批的多步操作应由 AI 或宿主客户端先明确询问用户:审批任务的 description 和每个有序 action 的 purpose 要使用什么语言。用户回答前不要调用 task_submit;回答后,两类人类可读描述应统一使用所选语言,toolName 和结构化 input 不做翻译。随后一次提交自然语言任务描述和有序、精确的 actions。每个 action 必须提供简短、具体的 purpose,单独说明该 Tool Call 要完成什么,不能只重复通用工具名称。Web Console 中的一次批准覆盖这份计划里的多个 Tool Call;批准后由服务端按顺序执行,客户端不要再逐个调用其中的工具。每个 action 仍会重新经过店铺授权、Policy、保护暂停、幂等和审计链。
任务描述用于向审批人解释意图,实际授权边界是 Console 中展示的工具、输入预览、顺序和 planHash。计划变化时应重新提交任务,不应在同一个已审批任务下追加调用。
推荐调用顺序:
- AI 明确询问用户审批任务描述和有序 action 目的要使用什么语言,并等待用户选择。
- AI 完成计划,确定所有工具、准确输入和执行顺序,使用所选语言撰写
description和每个purpose。 - 调用一次
task_submit,为每个 action 提供简短、具体的purpose,为每个写 action 提供独立的idempotencyKey,并使用稳定的clientTaskKey标识这份逻辑计划。 - 从返回值读取
task.id、task.planHash、task.status和task.executionStatus。status: "approval_required"表示整份计划正在等待一次人工审批。 - 等待审批时保留
task.id,随后调用task_get查询任务和逐 action 状态。task_get只能读取同一受管 MCP 客户端创建的任务。 - 不要在等待期间直接调用计划内的工具。若工具、输入、目的或顺序有变化,应使用新的
clientTaskKey重新调用task_submit。
下面的 TypeScript 示例使用已连接的 MCP SDK Client。桥接包本身只负责 stdio 与远程 HTTP 之间的协议转发,不额外提供 Shopify 业务 SDK。
import type { Client } from "@modelcontextprotocol/sdk/client/index.js";
type TaskStatus =
| "pending"
| "approved"
| "not_required"
| "rejected"
| "expired";
type ExecutionStatus =
| "not_started"
| "running"
| "succeeded"
| "partial"
| "failed";
type ActionStatus =
| "planned"
| "running"
| "succeeded"
| "cached"
| "failed"
| "skipped";
interface GovernedTask {
id: string;
planHash: string;
description: string;
status: TaskStatus;
executionStatus: ExecutionStatus;
executionError?: string;
actions: Array<{
id: string;
purpose: string;
toolName: string;
executionStatus: ActionStatus;
executionError?: string;
}>;
}
interface TaskSubmitResult {
status: "approval_required" | "rejected" | "running" | "completed" | "failed";
created: boolean;
task: GovernedTask;
}
interface TaskGetResult {
found: boolean;
task?: GovernedTask;
}
async function callStructured<T>(
client: Client,
name: string,
args: Record<string, unknown>
): Promise<T> {
const result = await client.callTool({ name, arguments: args });
if (result.isError || !result.structuredContent) {
throw new Error(
`MCP tool ${name} failed: ${JSON.stringify(result.content)}`
);
}
return result.structuredContent as unknown as T;
}
async function submitOrderReview(client: Client): Promise<TaskSubmitResult> {
return callStructured<TaskSubmitResult>(client, "task_submit", {
storeId: "dev-store",
clientTaskKey: "order-review-1001-v1",
description: "记录订单复核结论,并移除临时风险标签。",
actions: [
{
actionId: "write-review-note",
purpose: "记录复核结论",
toolName: "shopify_append_order_note",
input: {
idempotencyKey: "order-review-1001-note-v1",
orderId: "1001",
noteFragment: "Review completed."
}
},
{
actionId: "remove-risk-tag",
purpose: "移除已经处理的临时标签",
toolName: "shopify_remove_order_tags",
input: {
idempotencyKey: "order-review-1001-tag-v1",
orderId: "1001",
tags: ["needs-review"]
}
}
]
});
}
async function waitForTask(
client: Client,
taskId: string
): Promise<GovernedTask> {
for (;;) {
const result = await callStructured<TaskGetResult>(client, "task_get", {
taskId
});
if (!result.found || !result.task) {
throw new Error(`Task ${taskId} is not visible to this MCP client.`);
}
const task = result.task;
if (
task.executionStatus === "succeeded" ||
task.executionStatus === "partial" ||
task.executionStatus === "failed" ||
task.status === "rejected" ||
task.status === "expired"
) {
return task;
}
await new Promise((resolve) => setTimeout(resolve, 2_000));
}
}
export async function runOrderReview(client: Client): Promise<GovernedTask> {
const submitted = await submitOrderReview(client);
const finalTask =
submitted.task.executionStatus === "succeeded" ||
submitted.task.executionStatus === "partial" ||
submitted.task.executionStatus === "failed" ||
submitted.task.status === "rejected" ||
submitted.task.status === "expired"
? submitted.task
: await waitForTask(client, submitted.task.id);
if (finalTask.executionStatus !== "succeeded") {
const failedActions = finalTask.actions.filter(
(action) => action.executionStatus === "failed"
);
throw new Error(
`Task ${finalTask.id} ended as ${finalTask.status}/${finalTask.executionStatus}: ` +
JSON.stringify(failedActions)
);
}
return finalTask;
}
状态处理建议:
task_submit.status是本次提交的便捷摘要:approval_required表示等待整任务审批,running表示正在执行,completed表示已经成功完成,rejected表示任务被 Policy、审批人或过期状态拒绝,failed表示执行失败或只完成了部分 action。- 最终结果以
task.status、task.executionStatus和task.actions为准。executionStatus: "succeeded"是整体成功;partial或failed应检查每个 action 的executionError;rejected或expired不应重试原计划中的单个工具。 - action 的
succeeded和cached都表示该 action 已安全完成;skipped通常表示任务被拒绝,或前序 action 失败后停止执行。 clientTaskKey在同一客户端身份下具有幂等语义:相同 key 与相同计划会返回已有任务;相同 key 搭配不同计划会返回task_idempotency_conflict。不要用随机重试创建重复计划。- 示例采用固定间隔便于说明。生产客户端应增加最大等待时间、退避、取消信号和网络错误重试,并把
task.id持久化到当前会话或工作项中,而不是把 Token 或完整敏感输入写入日志。
发布
在仓库根目录执行:
npm run pack:client
npm run publish:client
以上命令从仓库根目录执行。客户端 publishConfig 已固定为 npm 官方 registry 的 public package;prepack 会自动生成 dist/,发布清单只包含编译后的桥接器和本 README。根目录服务器设置为 private,并有拒绝发布脚本作为额外保护。
License
MIT 2026 neilx2ye