npm.io
0.1.3 • Published 4h agoCLI

shopify-ops-mcp-client

Licence
MIT
Version
0.1.3
Deps
2
Size
36 kB
Vulns
0
Weekly
0

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_templatesworkflow_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。计划变化时应重新提交任务,不应在同一个已审批任务下追加调用。

推荐调用顺序:

  1. AI 明确询问用户审批任务描述和有序 action 目的要使用什么语言,并等待用户选择。
  2. AI 完成计划,确定所有工具、准确输入和执行顺序,使用所选语言撰写 description 和每个 purpose
  3. 调用一次 task_submit,为每个 action 提供简短、具体的 purpose,为每个写 action 提供独立的 idempotencyKey,并使用稳定的 clientTaskKey 标识这份逻辑计划。
  4. 从返回值读取 task.idtask.planHashtask.statustask.executionStatusstatus: "approval_required" 表示整份计划正在等待一次人工审批。
  5. 等待审批时保留 task.id,随后调用 task_get 查询任务和逐 action 状态。task_get 只能读取同一受管 MCP 客户端创建的任务。
  6. 不要在等待期间直接调用计划内的工具。若工具、输入、目的或顺序有变化,应使用新的 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.statustask.executionStatustask.actions 为准。executionStatus: "succeeded" 是整体成功;partialfailed 应检查每个 action 的 executionErrorrejectedexpired 不应重试原计划中的单个工具。
  • action 的 succeededcached 都表示该 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

Keywords