shopify-ops-mcp-client
v0.2.0
Published
OAuth-capable stdio bridge for a governed remote Shopify Ops MCP server.
Maintainers
Readme
shopify-ops-mcp-client
shopify-ops-mcp-client 是 Shopify Ops 远程 MCP 服务的轻量 stdio 桥接器。它把 MCP 协议消息通过 HTTPS 转发给中央 Streamable HTTP MCP 服务,并默认使用标准 MCP OAuth 自动管理 Access Token 和 Refresh Token。它不会保存 Shopify Admin token、店铺凭证或权限策略。
推荐方式:OAuth 自动刷新
安装到 MCP 宿主前,先在终端完成首次登录:
SHOPIFY_OPS_MCP_URL=https://mcp.example.com/mcp \
npx -y [email protected] login命令会通过 RFC 9728 发现授权服务器,使用 Authorization Code + PKCE S256 打开浏览器,并在本机 loopback 地址接收回调。请登录 Web Console,核对客户端和权限后批准。系统无法打开浏览器时,完整授权 URL 会写到 stderr,可手动复制到浏览器;stdout 始终保留给 MCP stdio 协议。
登录后,MCP 宿主只需启动同一个桥接器,不要配置 SHOPIFY_OPS_MCP_TOKEN:
SHOPIFY_OPS_MCP_URL=https://mcp.example.com/mcp \
npx -y [email protected]桥接器会在 Access Token 到期前刷新,并保存服务端轮换后的 Refresh Token;收到 401 时最多刷新并重试一次。Refresh Token 失效或需要切换账号/授权时,重新运行 login。只有显式 login 命令会打开浏览器;普通桥接进程不会在启动或 401 恢复路径发起交互授权,没有可用凭据时会直接提示先登录。
OAuth 凭据默认保存在:
- Linux/macOS:
${XDG_CONFIG_HOME:-~/.config}/shopify-ops-mcp/oauth-credentials.json - Windows:
%APPDATA%\shopify-ops-mcp\oauth-credentials.json
POSIX 系统中的凭据文件权限为 0600,新建的专用目录为 0700。可用绝对路径环境变量 SHOPIFY_OPS_MCP_OAUTH_CREDENTIALS_FILE 修改位置。默认回调端口为操作系统分配的随机端口;只有确实需要固定端口时才设置 SHOPIFY_OPS_MCP_OAUTH_CALLBACK_PORT。已动态注册的客户端可以在后续登录中复用同一个 Client ID,loopback 回调端口可以变化。
Refresh Token 和 Access Token 只由桥接器从专用凭据文件读取,不需要也不应放进宿主环境变量、提示词、MCP 工具参数或交给客户端 AI。子进程和服务端都无法安全修改 MCP 宿主父进程的环境变量,自动刷新更新的是凭据文件,不是 SHOPIFY_OPS_MCP_TOKEN。
服务端需要发布 RFC 9728 Protected Resource Metadata 和 RFC 8414 Authorization Server Metadata,并支持 PKCE S256。默认使用 Dynamic Client Registration;已有公共 Client ID 时可设置 SHOPIFY_OPS_MCP_OAUTH_CLIENT_ID。授权服务器支持 Client ID Metadata Document 时,也可改设 SHOPIFY_OPS_MCP_OAUTH_CLIENT_METADATA_URL,两者不能同时配置。自定义 scope 使用 SHOPIFY_OPS_MCP_OAUTH_SCOPES。
SHOPIFY_OPS_MCP_URL 默认是本地开发地址 http://127.0.0.1:3000/mcp。生产环境必须使用 HTTPS;HTTP 只允许 loopback 地址。
Codex 配置
先执行上面的 login,然后配置:
[mcp_servers.shopify_ops_mcp]
command = "npx"
args = ["-y", "[email protected]"]
startup_timeout_sec = 20
tool_timeout_sec = 120
[mcp_servers.shopify_ops_mcp.env]
SHOPIFY_OPS_MCP_URL = "https://mcp.example.com/mcp"Claude Code 配置
先执行上面的 login,然后配置:
{
"mcpServers": {
"shopify-ops-mcp": {
"command": "npx",
"args": ["-y", "[email protected]"],
"env": {
"SHOPIFY_OPS_MCP_URL": "https://mcp.example.com/mcp"
}
}
}
}静态 Bearer 兼容方式
宿主或服务端暂不支持标准 OAuth 时,仍可显式配置受管 Token:
SHOPIFY_OPS_MCP_URL=https://mcp.example.com/mcp \
SHOPIFY_OPS_MCP_TOKEN=replace-with-managed-client-token \
npx -y [email protected]SHOPIFY_OPS_MCP_TOKEN 存在时优先使用静态 Bearer,不会进入 OAuth 登录或自动刷新。不要把 Token 放在命令行参数中,因为参数可能出现在进程列表或 shell 历史里。每个 MCP 客户端或安装实例应使用服务器单独签发的 Token;Token binding 决定它能访问哪些店铺以及每个店铺的工具权限。请根据 Web Console 的过期时间和倒计时在到期前手动轮换、更新环境变量并重启宿主。
桥接器会原样转发客户端的 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
