mes-mcp 0.2.0 → 0.2.2

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/.env.example CHANGED
@@ -2,3 +2,4 @@ MES_BASE_URL=http://127.0.0.1:6033
2
2
  MES_API_PREFIX=/api
3
3
  MES_AGENT_TOKEN=
4
4
  MES_TIMEOUT_MS=30000
5
+ MES_REGISTER_DYNAMIC_TOOLS=false
package/README.md CHANGED
@@ -18,8 +18,11 @@ MES_BASE_URL=http://127.0.0.1:6033
18
18
  MES_API_PREFIX=/api
19
19
  MES_AGENT_TOKEN=BearerTokenWithoutBearerPrefix
20
20
  MES_TIMEOUT_MS=30000
21
+ MES_REGISTER_DYNAMIC_TOOLS=false
21
22
  ```
22
23
 
24
+ `MES_REGISTER_DYNAMIC_TOOLS` 默认关闭。关闭时 MCP 只注册稳定接口工具,通过后端动作目录发现并执行全部授权业务能力;打开后会把所有授权页面动作额外展开成 `mes.<动作编码>` 工具,适合明确需要工具直出且客户端能承载大量工具的场景。
25
+
23
26
  ## 开发
24
27
 
25
28
  ```bash
@@ -30,22 +33,26 @@ npm run dev
30
33
 
31
34
  ## 工具
32
35
 
33
- 使用后端动态业务动作:
36
+ 默认使用后端动态业务动作接口:
34
37
 
35
38
  - `mes_action.list`:查询当前凭证和当前账号权限允许调用的 MES 页面业务动作。
36
39
  - `mes_action.detail`:查看某个业务动作的路径、权限、DTO 名称和输入 schema。
37
40
  - `mes_action.execute`:按动作编码执行业务动作。
38
- - `mes.<动作编码>`:启动时从 `mes-node` 自动发现并注册的动态工具,例如 `mes.sale_order.create`、`mes.approval.handle`、`mes.quality_inspection.submit`、`mes.sale_delivery.confirm`。
41
+ - `mes.<动作编码>`:仅在 `MES_REGISTER_DYNAMIC_TOOLS=true` 时,从 `mes-node` 自动发现并注册的动态工具,例如 `mes.sale_order.create`、`mes.approval.handle`、`mes.quality_inspection.submit`、`mes.sale_delivery.confirm`。
42
+
43
+ 动态动作只调用 `mes-node` 的 `/integration/agent/actions/*` 接口。MCP 不保存字段 schema,不复制状态机,不做业务默认值;后端会复用页面 Controller、DTO、权限、租户、状态、审批、库存、质量和 AI 调用日志。默认不把全部动作展开成独立工具,是为了避免 MCP 客户端一次性加载数百个工具;能力覆盖仍以 `mes_action.list/detail/execute` 为准。
39
44
 
40
- 动态工具只调用 `mes-node` `/integration/agent/actions/*` 接口。MCP 不保存字段 schema,不复制状态机,不做业务默认值;后端会复用页面 Controller、DTO、权限、租户、状态、审批、库存、质量和 AI 调用日志。
45
+ 写入类动作通过 `confirm_required` `commit` 执行时需要幂等键。调用方没有显式传入时,`mes-mcp` 会为当前工具调用生成一条集成幂等键;需要跨重试严格复用同一幂等键的批处理场景,仍建议调用方显式传入 `idempotencyKey`。
41
46
 
42
47
  - `mes_api.read`:按当前 MES 账号权限读取普通业务接口,例如 `/sale-order/list`、`/material/detail`。后端只放行只读类接口,写入动作必须使用动态业务动作;读取调用会在 MES AI 调用日志里记录路径、请求摘要和结果摘要。
43
48
 
44
49
  写入类工具支持 `executionMode`:
45
50
 
46
51
  - `preview`:只预览,不写入。
47
- - `confirm_required`:生成待确认单,不写入;必须传 `idempotencyKey`。
48
- - `commit`:提交到 MES,按当前账号权限、工具白名单、业务规则和幂等键执行;必须传 `idempotencyKey`。
52
+ - `confirm_required`:生成待确认单,不写入;后端要求幂等键,未传时 MCP 会为本次工具调用自动生成。
53
+ - `commit`:提交到 MES,按当前账号权限、工具白名单、业务规则和幂等键执行;后端要求幂等键,未传时 MCP 会为本次工具调用自动生成。
54
+
55
+ 跨重试或批处理场景如果需要严格复用同一次业务请求,调用方应显式传入同一个 `idempotencyKey`;单次交互式调用可以依赖 MCP 自动生成。
49
56
 
50
57
  ## Marvis 接入建议
51
58
 
@@ -53,4 +60,4 @@ npm run dev
53
60
  2. `agentClientId` 建议填写 `marvis` 或具体渠道名。
54
61
  3. 授权范围建议先选“跟随账号权限”,最高执行模式建议先用 `confirm_required`;如果只想开放少量动作,再切换为“仅允许指定工具”。只读型 AI 凭证可以只选择 `mes_api.read`。
55
62
  4. 将返回的 JWT 配置为 `MES_AGENT_TOKEN`。
56
- 5. Marvis 侧通过 MCP 调用工具;图片、PDF、Excel 可先由 Marvis 识别成结构化草稿,再用 `mes_api.read` 或动态列表动作查询客户、物料等主数据,最后通过 `mes.<动作编码>` `mes_action.execute` 执行创建、审批、确认、排产、质检、出库等页面业务动作。
63
+ 5. Marvis 侧通过 MCP 调用工具;图片、PDF、Excel 可先由 Marvis 识别成结构化草稿,再用 `mes_api.read` 或动态列表动作查询客户、物料等主数据,最后通过 `mes_action.detail` `mes_action.execute` 执行创建、审批、确认、排产、质检、出库等页面业务动作。
@@ -1,5 +1,31 @@
1
+ function buildMesErrorMessage(path, status, message) {
2
+ const hints = [];
3
+ if (/pageSize must not be greater than 100/i.test(message)) {
4
+ hints.push("MES 分页上限是 100;请把 pageSize 调整到 100 或更小。");
5
+ }
6
+ if (message.includes("AI Agent 凭证只能访问动态业务动作接口")) {
7
+ hints.push("该路径可能属于受控动态业务动作或当前凭证未允许 mes_api.read;请优先用 mes_action.list/detail/execute 调用对应 actionCode。");
8
+ }
9
+ if (message.includes("最高只允许")) {
10
+ hints.push("当前 AI 凭证最高执行模式不足;请改用 preview/confirm_required,或在 MES 开放集成里创建允许 commit 的 AI 凭证。");
11
+ }
12
+ if (message.includes("待确认 AI 调用不存在")) {
13
+ hints.push("AI 待确认单按公司/租户隔离;请使用生成该确认单的同一公司账号审批,并确认 invocationId 未过期或未被处理。");
14
+ }
15
+ if (/^\/?integration\/agent(\/|$)/i.test(path)) {
16
+ hints.push("不要通过 mes_api.read 访问 /integration/agent/*;请使用 mes_action.* 专用工具。");
17
+ }
18
+ return [
19
+ `MES ${status}: ${message}`,
20
+ `path: ${path}`,
21
+ hints.length > 0 ? `hints: ${hints.join(" ")}` : undefined,
22
+ ]
23
+ .filter(Boolean)
24
+ .join("\n");
25
+ }
1
26
  export class MesAgentApiClient {
2
27
  config;
28
+ businessActionsPromise;
3
29
  constructor(config) {
4
30
  this.config = config;
5
31
  }
@@ -20,10 +46,10 @@ export class MesAgentApiClient {
20
46
  const body = this.parseBody(text);
21
47
  if (!response.ok) {
22
48
  const message = body?.msg || body?.message || text || response.statusText;
23
- throw new Error(`MES ${response.status}: ${message}`);
49
+ throw new Error(buildMesErrorMessage(path, response.status, message));
24
50
  }
25
51
  if (body && typeof body.code === "number" && body.code !== 0) {
26
- throw new Error(body.msg || body.message || `MES code ${body.code}`);
52
+ throw new Error(buildMesErrorMessage(path, body.code, body.msg || body.message || `MES code ${body.code}`));
27
53
  }
28
54
  return (body && "data" in body ? body.data : body);
29
55
  }
@@ -41,7 +67,26 @@ export class MesAgentApiClient {
41
67
  }
42
68
  return this.post(normalized, payload);
43
69
  }
44
- async listBusinessActions() {
70
+ async listBusinessActions(options = {}) {
71
+ if (!options.refresh && this.businessActionsPromise) {
72
+ return this.businessActionsPromise;
73
+ }
74
+ this.businessActionsPromise = this.fetchBusinessActions().catch((error) => {
75
+ this.businessActionsPromise = undefined;
76
+ throw error;
77
+ });
78
+ return this.businessActionsPromise;
79
+ }
80
+ async getBusinessAction(actionCode) {
81
+ const actions = await this.listBusinessActions();
82
+ const cached = actions.find((action) => action.actionCode === actionCode);
83
+ if (cached)
84
+ return cached;
85
+ const refreshedActions = await this.listBusinessActions({ refresh: true });
86
+ return (refreshedActions.find((action) => action.actionCode === actionCode) ??
87
+ null);
88
+ }
89
+ async fetchBusinessActions() {
45
90
  const pageSize = 100;
46
91
  const firstPage = await this.post("/integration/agent/actions/list", { page: 1, pageSize });
47
92
  if (firstPage.total <= firstPage.list.length)
package/dist/config.js CHANGED
@@ -3,6 +3,10 @@ function optionalEnv(name, fallback) {
3
3
  const value = process.env[name]?.trim();
4
4
  return value && value.length > 0 ? value : fallback;
5
5
  }
6
+ function optionalRawEnv(name) {
7
+ const value = process.env[name]?.trim();
8
+ return value && value.length > 0 ? value : undefined;
9
+ }
6
10
  function requiredEnv(name) {
7
11
  const value = process.env[name]?.trim();
8
12
  if (!value) {
@@ -10,15 +14,81 @@ function requiredEnv(name) {
10
14
  }
11
15
  return value;
12
16
  }
17
+ function optionalBooleanEnv(name, fallback) {
18
+ const value = process.env[name]?.trim().toLowerCase();
19
+ if (!value)
20
+ return fallback;
21
+ if (["1", "true", "yes", "on"].includes(value))
22
+ return true;
23
+ if (["0", "false", "no", "off"].includes(value))
24
+ return false;
25
+ throw new Error(`${name} must be a boolean`);
26
+ }
27
+ function decodeBase64UrlJson(segment) {
28
+ try {
29
+ const normalized = segment.replace(/-/g, "+").replace(/_/g, "/");
30
+ const padded = normalized.padEnd(normalized.length + ((4 - (normalized.length % 4)) % 4), "=");
31
+ return JSON.parse(Buffer.from(padded, "base64").toString("utf8"));
32
+ }
33
+ catch {
34
+ return null;
35
+ }
36
+ }
37
+ function asString(value) {
38
+ return typeof value === "string" && value.length > 0 ? value : undefined;
39
+ }
40
+ function asNumber(value) {
41
+ return typeof value === "number" && Number.isFinite(value)
42
+ ? value
43
+ : undefined;
44
+ }
45
+ function decodeAgentToken(token) {
46
+ const [, payloadSegment] = token.split(".");
47
+ if (!payloadSegment)
48
+ return {};
49
+ const payload = decodeBase64UrlJson(payloadSegment);
50
+ if (!payload)
51
+ return {};
52
+ return {
53
+ subject: asString(payload.sub),
54
+ username: asString(payload.username),
55
+ tokenType: asString(payload.tokenType),
56
+ apiKeyId: asString(payload.apiKeyId),
57
+ clientType: asString(payload.clientType),
58
+ agentClientId: asString(payload.agentClientId),
59
+ issuedAt: asNumber(payload.iat),
60
+ expiresAt: asNumber(payload.exp),
61
+ };
62
+ }
63
+ function assertExpectedTokenValue(label, actual, expected) {
64
+ if (!expected)
65
+ return;
66
+ if (actual === expected)
67
+ return;
68
+ throw new Error(`${label} mismatch for MES_AGENT_TOKEN: expected ${expected}, got ${actual ?? "<missing>"}`);
69
+ }
13
70
  export function loadConfig() {
14
71
  const timeoutMs = Number(optionalEnv("MES_TIMEOUT_MS", "30000"));
15
72
  if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
16
73
  throw new Error("MES_TIMEOUT_MS must be a positive number");
17
74
  }
75
+ const mesAgentToken = requiredEnv("MES_AGENT_TOKEN");
76
+ const tokenInfo = decodeAgentToken(mesAgentToken);
77
+ const expectedUsername = optionalRawEnv("MES_EXPECTED_USERNAME");
78
+ const expectedAgentClientId = optionalRawEnv("MES_EXPECTED_AGENT_CLIENT_ID");
79
+ const expectedApiKeyId = optionalRawEnv("MES_EXPECTED_API_KEY_ID");
80
+ assertExpectedTokenValue("username", tokenInfo.username, expectedUsername);
81
+ assertExpectedTokenValue("agentClientId", tokenInfo.agentClientId, expectedAgentClientId);
82
+ assertExpectedTokenValue("apiKeyId", tokenInfo.apiKeyId, expectedApiKeyId);
18
83
  return {
19
84
  mesBaseUrl: optionalEnv("MES_BASE_URL", "http://127.0.0.1:6033").replace(/\/+$/, ""),
20
85
  mesApiPrefix: optionalEnv("MES_API_PREFIX", "/api").replace(/\/+$/, ""),
21
- mesAgentToken: requiredEnv("MES_AGENT_TOKEN"),
86
+ mesAgentToken,
22
87
  timeoutMs,
88
+ registerDynamicTools: optionalBooleanEnv("MES_REGISTER_DYNAMIC_TOOLS", false),
89
+ tokenInfo,
90
+ expectedUsername,
91
+ expectedAgentClientId,
92
+ expectedApiKeyId,
23
93
  };
24
94
  }
package/dist/index.js CHANGED
@@ -6,14 +6,50 @@ import { MesAgentApiClient } from "./clients/mes-agent-api.client.js";
6
6
  import { registerMesPrompts } from "./prompts/register-prompts.js";
7
7
  import { registerMesResources } from "./resources/register-resources.js";
8
8
  import { registerAllAgentTools } from "./tools/register-tools.js";
9
+ function maskId(value) {
10
+ if (!value)
11
+ return "<unknown>";
12
+ if (value.length <= 12)
13
+ return value;
14
+ return `${value.slice(0, 8)}...${value.slice(-4)}`;
15
+ }
16
+ function formatUnixSeconds(value) {
17
+ if (!value)
18
+ return "<unknown>";
19
+ const date = new Date(value * 1000);
20
+ if (Number.isNaN(date.getTime()))
21
+ return "<invalid>";
22
+ return date.toISOString();
23
+ }
24
+ function logStartupDiagnostics(config) {
25
+ const lines = [
26
+ "[mes-mcp] starting",
27
+ `[mes-mcp] endpoint=${config.mesBaseUrl}${config.mesApiPrefix}`,
28
+ `[mes-mcp] token username=${config.tokenInfo.username ?? "<unknown>"} apiKeyId=${maskId(config.tokenInfo.apiKeyId)} agentClientId=${config.tokenInfo.agentClientId ?? "<unknown>"} expiresAt=${formatUnixSeconds(config.tokenInfo.expiresAt)}`,
29
+ `[mes-mcp] dynamicTools=${config.registerDynamicTools ? "enabled" : "disabled"} (${config.registerDynamicTools ? "mes.<actionCode> tools will be registered" : "use mes_action.list/detail/execute; set MES_REGISTER_DYNAMIC_TOOLS=true only when the client needs expanded tools"})`,
30
+ ];
31
+ if (config.expectedUsername) {
32
+ lines.push(`[mes-mcp] expected username=${config.expectedUsername}`);
33
+ }
34
+ if (config.expectedAgentClientId) {
35
+ lines.push(`[mes-mcp] expected agentClientId=${config.expectedAgentClientId}`);
36
+ }
37
+ if (config.expectedApiKeyId) {
38
+ lines.push(`[mes-mcp] expected apiKeyId=${maskId(config.expectedApiKeyId)}`);
39
+ }
40
+ console.error(lines.join("\n"));
41
+ }
9
42
  async function main() {
10
43
  const config = loadConfig();
44
+ logStartupDiagnostics(config);
11
45
  const client = new MesAgentApiClient(config);
12
46
  const server = new McpServer({
13
47
  name: "mes-mcp",
14
- version: "0.2.0",
48
+ version: "0.2.2",
49
+ });
50
+ await registerAllAgentTools(server, client, {
51
+ registerDynamicTools: config.registerDynamicTools,
15
52
  });
16
- await registerAllAgentTools(server, client);
17
53
  registerMesResources(server, client);
18
54
  registerMesPrompts(server);
19
55
  await server.connect(new StdioServerTransport());
@@ -15,9 +15,11 @@ export function registerMesPrompts(server) {
15
15
  text: [
16
16
  "你是 MES 业务助手,必须按当前账号权限和工具返回结果执行。",
17
17
  "不要猜测客户、物料、BOM、工艺路线、仓库等关键主数据;缺失时先查询或要求用户确认。",
18
- "先用 mes_action.list 或 mes://agent/actions 发现当前账号能做的页面业务动作;发现到动态工具 mes.<动作编码> 时,直接调用对应工具。",
18
+ "默认先用 mes_action.list 或 mes://agent/actions 发现当前账号能做的页面业务动作,再用 mes_action.detail 查看字段,最后用 mes_action.execute 执行;不要依赖工具列表里必须存在 mes.<动作编码>。",
19
+ "mes_action.execute 返回的是执行信封,真实业务返回在 data 字段;confirm_required 产生的 invocationId 必须由同一公司/租户下有权限的账号审批。",
20
+ "mes_api.read 只用于普通业务只读接口,pageSize 最大 100;如果返回 _mcpWarnings 或提示需要动态动作,请按提示调整。",
19
21
  "销售到出库的流程也走动态动作:先查客户和物料,再创建销售单、处理审批、确认销售单、转生产计划、排产或生成工单、报工、质检、发货出库。",
20
- "写入类动作默认先使用 confirm_required 或 preview;confirm_required commit 都必须传 idempotencyKey,preview 不写入。",
22
+ "写入类动作默认先使用 confirm_required 或 preview;preview 不写入,confirm_required 生成待确认记录,commit 只有凭证允许时才真实提交。",
21
23
  context ? `当前上下文:${context}` : "",
22
24
  ]
23
25
  .filter(Boolean)
@@ -4,18 +4,71 @@ export function registerMesResources(server, client) {
4
4
  description: "当前 AI 凭证和账号权限可调用的 MES 页面业务动作目录,来源于 mes-node 后端 Controller、权限和 DTO 元数据。",
5
5
  mimeType: "application/json",
6
6
  }, async (uri) => {
7
- const actions = await client.post("/integration/agent/actions/list", {
8
- page: 1,
9
- pageSize: 100,
10
- });
7
+ const actions = await client.listBusinessActions();
11
8
  return {
12
9
  contents: [
13
10
  {
14
11
  uri: uri.href,
15
12
  mimeType: "application/json",
16
- text: JSON.stringify(actions, null, 2),
13
+ text: JSON.stringify({
14
+ list: actions,
15
+ total: actions.length,
16
+ page: 1,
17
+ pageSize: actions.length,
18
+ }, null, 2),
17
19
  },
18
20
  ],
19
21
  };
20
22
  });
23
+ server.registerResource("mes_agent_context", "mes://agent/context", {
24
+ title: "MES Agent Context",
25
+ description: "MES AI Agent 操作边界、标准链路和安全规则摘要,用于提示客户端按受控接口完成业务闭环。",
26
+ mimeType: "text/markdown",
27
+ }, async (uri) => ({
28
+ contents: [
29
+ {
30
+ uri: uri.href,
31
+ mimeType: "text/markdown",
32
+ text: [
33
+ "# MES Agent Context",
34
+ "",
35
+ "MES MCP 只做协议适配,业务事实、权限、租户隔离、状态机、库存、质量、幂等和审计均由 mes-node 负责。",
36
+ "",
37
+ "## 工具使用顺序",
38
+ "",
39
+ "1. 使用 `mes_action.list` 或 `mes://agent/actions` 发现当前凭证和账号权限允许的动作。",
40
+ "2. 使用 `mes_action.detail` 查看目标动作的路径、权限、DTO 名称和输入 schema。",
41
+ "3. 只读查询使用 `mes_api.read` 调用普通业务只读 POST 接口。",
42
+ "4. 写入、确认、下达、报工、质检、出入库等动作使用 `mes_action.execute` 或可选的 `mes.<动作编码>`。",
43
+ "5. `MES_REGISTER_DYNAMIC_TOOLS=false` 是默认推荐配置;此时工具列表只有 `mes_api.read`、`mes_action.list`、`mes_action.detail`、`mes_action.execute` 等基础工具。只有客户端必须直观看到 `mes.<动作编码>` 时才开启动态工具展开。",
44
+ "",
45
+ "## 写入规则",
46
+ "",
47
+ "- `preview` 不写入业务数据。",
48
+ "- `confirm_required` 生成待确认调用,不直接写入。",
49
+ "- `commit` 只有凭证最高执行模式允许时才真实提交。",
50
+ "- 写入类 `confirm_required` 和 `commit` 必须有幂等键;MCP 会为单次工具调用自动生成,跨重试批处理应显式传入同一个 `idempotencyKey`。",
51
+ "- `mes_action.execute` 返回执行信封,真实业务返回在 `data` 字段;`invocationId` 是 AI 调用日志 / 待确认单的标识。",
52
+ "- 待确认单按公司/租户隔离,必须由生成确认单的同一公司下有权限的账号审批。",
53
+ "",
54
+ "## 读取和分页",
55
+ "",
56
+ "- `mes_api.read` 只用于普通业务只读接口,不用于 `/integration/agent/*`。",
57
+ "- MES 分页 `pageSize` 最大为 100;MCP 会裁剪超限值,并通过 `_mcpWarnings` 返回提示。",
58
+ "- 如果只读路径被提示需要动态业务动作,请改用 `mes_action.list/detail/execute` 查找并调用对应 actionCode。",
59
+ "",
60
+ "## 防误连",
61
+ "",
62
+ "- MCP 启动时会在 stderr 打印 MES 地址、Token 用户名、apiKeyId、agentClientId 和动态工具状态。",
63
+ "- 可配置 `MES_EXPECTED_USERNAME`、`MES_EXPECTED_AGENT_CLIENT_ID`、`MES_EXPECTED_API_KEY_ID` 做本地启动校验,避免使用旧 `.env` 误连错误账号或凭证。",
64
+ "",
65
+ "## 禁止事项",
66
+ "",
67
+ "- 不猜客户、物料、BOM、工艺路线、仓库、批次、数量、价格等关键业务事实。",
68
+ "- 不通过 `mes_api.read` 调用创建、更新、删除、确认、下达等写入接口。",
69
+ "- 不绕过当前账号权限、公司隔离、状态机、库存规则、质量规则和审计。",
70
+ ].join("\n"),
71
+ },
72
+ ],
73
+ }));
21
74
  }
@@ -1,4 +1,6 @@
1
+ import { randomUUID } from "node:crypto";
1
2
  import { z } from "zod";
3
+ const MAX_MES_PAGE_SIZE = 100;
2
4
  const ExecutionModeSchema = z
3
5
  .enum(["preview", "confirm_required", "commit"])
4
6
  .optional();
@@ -10,30 +12,101 @@ const DynamicActionSchema = z
10
12
  .min(1)
11
13
  .max(160)
12
14
  .optional()
13
- .describe("写入动作 confirm_required 或 commit 时必填,用于后端幂等。"),
15
+ .describe("写入动作 confirm_required 或 commit 时用于后端幂等;不传时 MCP 会为本次工具调用自动生成。"),
14
16
  payload: z
15
17
  .record(z.string(), z.unknown())
16
18
  .optional()
17
19
  .describe("MES 页面业务动作请求体;也可以直接把 DTO 字段放在顶层。"),
18
20
  })
19
21
  .passthrough();
20
- function formatToolResult(result) {
22
+ function withWarnings(result, warnings) {
23
+ if (result && typeof result === "object" && !Array.isArray(result)) {
24
+ return {
25
+ ...result,
26
+ ...(warnings.length > 0 ? { _mcpWarnings: warnings } : {}),
27
+ };
28
+ }
29
+ return {
30
+ value: result,
31
+ ...(warnings.length > 0 ? { _mcpWarnings: warnings } : {}),
32
+ };
33
+ }
34
+ function formatToolResult(result, warnings = []) {
35
+ const structuredContent = withWarnings(result, warnings);
36
+ return {
37
+ structuredContent,
38
+ content: [
39
+ {
40
+ type: "text",
41
+ text: JSON.stringify(structuredContent, null, 2),
42
+ },
43
+ ],
44
+ };
45
+ }
46
+ function errorMessage(error) {
47
+ return error instanceof Error ? error.message : String(error);
48
+ }
49
+ function errorHints(message) {
50
+ const hints = [];
51
+ if (/pageSize/i.test(message)) {
52
+ hints.push("MES 分页上限是 100;MCP 会对 mes_api.read 和 mes_action.list 自动裁剪 pageSize。");
53
+ }
54
+ if (message.includes("AI Agent 凭证只能访问动态业务动作接口")) {
55
+ hints.push("请改用 mes_action.list/detail/execute 查找并调用对应 actionCode。");
56
+ }
57
+ if (message.includes("最高只允许")) {
58
+ hints.push("请改用 preview/confirm_required,或重新创建允许 commit 的 AI 凭证。");
59
+ }
60
+ if (message.includes("待确认 AI 调用不存在")) {
61
+ hints.push("确认单按公司/租户隔离;请切回生成确认单的同一公司账号审批。");
62
+ }
63
+ return hints;
64
+ }
65
+ function formatToolError(error, context) {
66
+ const message = errorMessage(error);
67
+ const structuredContent = {
68
+ error: {
69
+ message,
70
+ toolName: context.toolName,
71
+ ...(context.actionCode ? { actionCode: context.actionCode } : {}),
72
+ ...(context.path ? { path: context.path } : {}),
73
+ hints: errorHints(message),
74
+ },
75
+ };
21
76
  return {
22
- structuredContent: result && typeof result === "object" && !Array.isArray(result)
23
- ? result
24
- : { value: result },
77
+ isError: true,
78
+ structuredContent,
25
79
  content: [
26
80
  {
27
81
  type: "text",
28
- text: JSON.stringify(result, null, 2),
82
+ text: JSON.stringify(structuredContent, null, 2),
29
83
  },
30
84
  ],
31
85
  };
32
86
  }
87
+ async function runTool(context, handler) {
88
+ try {
89
+ return await handler();
90
+ }
91
+ catch (error) {
92
+ return formatToolError(error, context);
93
+ }
94
+ }
95
+ function normalizePageSize(payload, warnings) {
96
+ const pageSize = payload.pageSize;
97
+ if (typeof pageSize !== "number" || pageSize <= MAX_MES_PAGE_SIZE) {
98
+ return payload;
99
+ }
100
+ warnings.push({
101
+ code: "pageSize_clamped",
102
+ message: `MES pageSize 最大为 ${MAX_MES_PAGE_SIZE},已从 ${pageSize} 自动调整为 ${MAX_MES_PAGE_SIZE}。`,
103
+ });
104
+ return { ...payload, pageSize: MAX_MES_PAGE_SIZE };
105
+ }
33
106
  export function registerAgentTools(server, client) {
34
107
  server.registerTool("mes_api.read", {
35
108
  title: "Read MES API",
36
- description: "按当前 MES 账号权限读取系统内业务信息。仅用于 list/detail/view/read/export/stats/options 等只读类 POST 接口;创建、更新、删除、确认、下达等写入动作必须使用专门的 Agent 工具。",
109
+ description: "按当前 MES 账号权限读取系统内业务信息。仅用于普通业务的 list/detail/view/read/export/stats/options 等只读类 POST 接口;创建、更新、删除、确认、下达等写入动作必须使用 mes_action.execute。分页 pageSize 最大为 100,MCP 会自动裁剪超限值并在 _mcpWarnings 中提示。",
37
110
  inputSchema: z
38
111
  .object({
39
112
  path: z
@@ -52,14 +125,18 @@ export function registerAgentTools(server, client) {
52
125
  idempotentHint: true,
53
126
  },
54
127
  }, async (args) => {
55
- const parsed = z
56
- .object({
57
- path: z.string().min(1),
58
- payload: z.record(z.string(), z.unknown()).optional(),
59
- })
60
- .parse(args);
61
- const result = await client.postApiPath(parsed.path, parsed.payload ?? {});
62
- return formatToolResult(result);
128
+ return runTool({ toolName: "mes_api.read" }, async () => {
129
+ const parsed = z
130
+ .object({
131
+ path: z.string().min(1),
132
+ payload: z.record(z.string(), z.unknown()).optional(),
133
+ })
134
+ .parse(args);
135
+ const warnings = [];
136
+ const payload = normalizePageSize(parsed.payload ?? {}, warnings);
137
+ const result = await client.postApiPath(parsed.path, payload);
138
+ return formatToolResult(result, warnings);
139
+ });
63
140
  });
64
141
  }
65
142
  function normalizeDynamicArgs(args) {
@@ -71,19 +148,28 @@ function normalizeDynamicArgs(args) {
71
148
  payload: payload ?? rest,
72
149
  };
73
150
  }
151
+ function buildIdempotencyKey(actionCode) {
152
+ const normalizedActionCode = actionCode.replace(/[^a-zA-Z0-9._-]+/g, "_");
153
+ return `mcp-${normalizedActionCode}-${Date.now().toString(36)}-${randomUUID()}`.slice(0, 160);
154
+ }
155
+ function shouldEnsureIdempotencyKey(action, executionMode) {
156
+ return Boolean(action?.isWrite && executionMode !== "preview");
157
+ }
74
158
  function actionDescription(action) {
75
159
  const permissionText = action.permissions.length > 0
76
160
  ? `需要权限:${action.permissions.join("、")}。`
77
161
  : "按后端业务方法自行校验权限。";
78
162
  const modeText = action.isWrite
79
- ? "写入动作,confirm_required 会生成待确认单,commit 会真实写入 MES;两种模式都必须提供 idempotencyKeypreview 不写入。"
163
+ ? "写入动作,confirm_required 会生成待确认单,commit 会真实写入 MES;两种模式可显式提供 idempotencyKey,不传时 MCP 会为本次工具调用自动生成,preview 不写入。"
80
164
  : "只读动作,固定按 preview 执行。";
165
+ const resultText = "返回格式固定为 { actionCode, executionMode, status, invocationId, bizObjectType, bizObjectId, data };真实业务返回在 data 字段。";
81
166
  return [
82
167
  action.description,
83
168
  `页面接口:POST ${action.path}。`,
84
169
  action.inputDto ? `请求 DTO:${action.inputDto}。` : undefined,
85
170
  permissionText,
86
171
  modeText,
172
+ resultText,
87
173
  ]
88
174
  .filter(Boolean)
89
175
  .join("\n");
@@ -109,8 +195,24 @@ function registerGenericActionTools(server, client) {
109
195
  idempotentHint: true,
110
196
  },
111
197
  }, async (args) => {
112
- const result = await client.post("/integration/agent/actions/list", args ?? {});
113
- return formatToolResult(result);
198
+ return runTool({ toolName: "mes_action.list" }, async () => {
199
+ const parsed = z
200
+ .object({
201
+ page: z.number().int().positive().optional(),
202
+ pageSize: z.number().int().positive().optional(),
203
+ keyword: z.string().optional(),
204
+ module: z.string().optional(),
205
+ resource: z.string().optional(),
206
+ operation: z.string().optional(),
207
+ write: z.boolean().optional(),
208
+ })
209
+ .passthrough()
210
+ .parse(args ?? {});
211
+ const warnings = [];
212
+ const payload = normalizePageSize(parsed, warnings);
213
+ const result = await client.post("/integration/agent/actions/list", payload);
214
+ return formatToolResult(result, warnings);
215
+ });
114
216
  });
115
217
  server.registerTool("mes_action.detail", {
116
218
  title: "Get MES business action detail",
@@ -124,12 +226,19 @@ function registerGenericActionTools(server, client) {
124
226
  idempotentHint: true,
125
227
  },
126
228
  }, async (args) => {
127
- const result = await client.post("/integration/agent/actions/detail", args ?? {});
128
- return formatToolResult(result);
229
+ return runTool({ toolName: "mes_action.detail" }, async () => {
230
+ const parsed = z
231
+ .object({
232
+ actionCode: z.string().min(1),
233
+ })
234
+ .parse(args ?? {});
235
+ const result = await client.post("/integration/agent/actions/detail", parsed);
236
+ return formatToolResult(result);
237
+ });
129
238
  });
130
239
  server.registerTool("mes_action.execute", {
131
240
  title: "Execute MES business action",
132
- description: "执行一个由 mes_action.list 发现的 MES 页面业务动作。后端复用页面 Controller、DTO、权限、租户、状态机和调用日志。",
241
+ description: "执行一个由 mes_action.list 发现的 MES 页面业务动作。后端复用页面 Controller、DTO、权限、租户、状态机和调用日志。返回体是执行信封:真实业务结果在 data 字段;confirm_required 返回待确认 invocationId,必须由同一公司/租户下有权限的账号审批。",
133
242
  inputSchema: z
134
243
  .object({
135
244
  actionCode: z.string().min(1),
@@ -144,23 +253,29 @@ function registerGenericActionTools(server, client) {
144
253
  idempotentHint: true,
145
254
  },
146
255
  }, async (args) => {
147
- const parsed = z
148
- .object({
149
- actionCode: z.string().min(1),
150
- executionMode: ExecutionModeSchema,
151
- idempotencyKey: z.string().min(1).max(160).optional(),
152
- payload: z.record(z.string(), z.unknown()).optional(),
153
- })
154
- .passthrough()
155
- .parse(args ?? {});
156
- const { actionCode, executionMode, idempotencyKey, payload, ...rest } = parsed;
157
- const result = await client.post("/integration/agent/actions/execute", {
158
- actionCode,
159
- executionMode,
160
- idempotencyKey,
161
- payload: payload ?? rest,
256
+ return runTool({ toolName: "mes_action.execute" }, async () => {
257
+ const parsed = z
258
+ .object({
259
+ actionCode: z.string().min(1),
260
+ executionMode: ExecutionModeSchema,
261
+ idempotencyKey: z.string().min(1).max(160).optional(),
262
+ payload: z.record(z.string(), z.unknown()).optional(),
263
+ })
264
+ .passthrough()
265
+ .parse(args ?? {});
266
+ const { actionCode, executionMode, idempotencyKey, payload, ...rest } = parsed;
267
+ const action = await client.getBusinessAction(actionCode);
268
+ const result = await client.post("/integration/agent/actions/execute", {
269
+ actionCode,
270
+ executionMode,
271
+ idempotencyKey: idempotencyKey ??
272
+ (shouldEnsureIdempotencyKey(action, executionMode)
273
+ ? buildIdempotencyKey(actionCode)
274
+ : undefined),
275
+ payload: payload ?? rest,
276
+ });
277
+ return formatToolResult(result);
162
278
  });
163
- return formatToolResult(result);
164
279
  });
165
280
  }
166
281
  async function registerDynamicBusinessActionTools(server, client) {
@@ -181,18 +296,26 @@ async function registerDynamicBusinessActionTools(server, client) {
181
296
  idempotentHint: true,
182
297
  },
183
298
  }, async (args) => {
184
- const normalized = normalizeDynamicArgs(args);
185
- const result = await client.post("/integration/agent/actions/execute", {
186
- actionCode: action.actionCode,
187
- ...normalized,
299
+ return runTool({ toolName, actionCode: action.actionCode }, async () => {
300
+ const normalized = normalizeDynamicArgs(args);
301
+ const result = await client.post("/integration/agent/actions/execute", {
302
+ actionCode: action.actionCode,
303
+ ...normalized,
304
+ idempotencyKey: normalized.idempotencyKey ??
305
+ (shouldEnsureIdempotencyKey(action, normalized.executionMode)
306
+ ? buildIdempotencyKey(action.actionCode)
307
+ : undefined),
308
+ });
309
+ return formatToolResult(result);
188
310
  });
189
- return formatToolResult(result);
190
311
  });
191
312
  }
192
313
  }
193
- export async function registerAllAgentTools(server, client) {
314
+ export async function registerAllAgentTools(server, client, options = {}) {
194
315
  registerAgentTools(server, client);
195
316
  registerGenericActionTools(server, client);
317
+ if (!options.registerDynamicTools)
318
+ return;
196
319
  try {
197
320
  await registerDynamicBusinessActionTools(server, client);
198
321
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mes-mcp",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "type": "module",
5
5
  "description": "MES MCP adapter for Marvis and AI agents",
6
6
  "license": "UNLICENSED",