diff --git a/CHANGELOG.md b/CHANGELOG.md
index 88a0fa50..a5787ef0 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -29,6 +29,9 @@
### Fixed
+- #314:招聘/编辑能力面板在绑定任一 MCP 连接器时给出明确警告——当前全部内置引擎都会在第一回合以 `qoder.mcp_binding_unsupported` 拒绝员工级 MCP,绑定仍可保存,但不再静默让操作员以为该能力已经可用。
+- #243:Agent Host 登记顺序改为从详尽的 `HOST_DEFINITIONS` 派生,新增引擎时不能再静默漏掉。
+
- #325: The employee card now keeps its per-task and per-day budget declarations visible after turns instead of replacing the declaration with latest-turn usage. Usage remains available in the reports surfaces, where over-budget percentages stay truthful while the visual meter fill is bounded to its track.
- #297:创建数字员工默认先展示 Agent 与基础信息;Agent 草案、头像、权限、能力、记忆、运行模式和预算改为按需展开,保留安全默认值、原有字段校验与 hire 请求 payload。
diff --git a/apps/desktop/renderer/src/app.css b/apps/desktop/renderer/src/app.css
index fffad33c..d398c7b9 100644
--- a/apps/desktop/renderer/src/app.css
+++ b/apps/desktop/renderer/src/app.css
@@ -3422,6 +3422,7 @@
.owb-hire-mcp__tools { display: flex; flex-wrap: wrap; gap: 4px; padding: 0 4px; }
.owb-hire-mcp__tools button { border: 1px solid var(--ui-border); border-radius: 999px; background: transparent; color: var(--ui-foreground-muted); padding: 3px 7px; font: 9px var(--ui-font-mono, monospace); cursor: pointer; }
.owb-hire-mcp__tools button.is-selected { border-color: color-mix(in srgb, var(--ui-success) 58%, var(--ui-border)); background: color-mix(in srgb, var(--ui-success) 15%, transparent); color: var(--ui-success); }
+.owb-hire-mcp-unsupported { margin: 0; padding: 8px 10px; border: 1px solid color-mix(in srgb, var(--ui-warning, #d97706) 45%, var(--ui-border)); border-radius: 8px; background: color-mix(in srgb, var(--ui-warning, #d97706) 12%, var(--ui-surface)); color: var(--ui-foreground); font-size: 11px; line-height: 1.45; }
.owb-hire-capability-note { margin: 0; color: var(--ui-foreground-muted); font-size: 10px; line-height: 1.4; }
.owb-tag--mcp { border-color: color-mix(in srgb, var(--ui-info, var(--ui-primary)) 46%, var(--ui-border)); color: var(--ui-info, var(--ui-primary)); }
diff --git a/apps/desktop/renderer/src/org/PermissionsEditor.tsx b/apps/desktop/renderer/src/org/PermissionsEditor.tsx
index d14ed4d6..5e763c44 100644
--- a/apps/desktop/renderer/src/org/PermissionsEditor.tsx
+++ b/apps/desktop/renderer/src/org/PermissionsEditor.tsx
@@ -231,6 +231,11 @@ export function CapabilityPicker({
+ {(permissions.mcpServers ?? []).length > 0 ? (
+
+ {t("hire.mcpUnsupportedHint")}
+
+ ) : null}
{t("hire.capabilityPermissionHint")}
);
diff --git a/apps/desktop/renderer/test/capability-picker.test.tsx b/apps/desktop/renderer/test/capability-picker.test.tsx
new file mode 100644
index 00000000..5fb1e97f
--- /dev/null
+++ b/apps/desktop/renderer/test/capability-picker.test.tsx
@@ -0,0 +1,43 @@
+import { useState } from "react";
+import { fireEvent, render, screen } from "@testing-library/react";
+import { describe, expect, it } from "vitest";
+import type { HirePermissions } from "@roleweave/shared";
+import { CapabilityPicker } from "../src/org/PermissionsEditor";
+
+const EMPTY: HirePermissions = {
+ tools: ["Read"],
+ rules: [{ scope: "position", resource: "./knowledge/**", actions: ["read"] }],
+ skills: [],
+ mcpServers: [],
+};
+
+function Harness({ initial = EMPTY }: { initial?: HirePermissions }) {
+ const [permissions, setPermissions] = useState(initial);
+ return ;
+}
+
+describe("CapabilityPicker MCP hire warning (#314)", () => {
+ it("does not warn until an MCP connector is bound", () => {
+ render();
+ expect(screen.queryByRole("status")).toBeNull();
+ });
+
+ it("warns that bundled engines reject employee MCP as soon as a server is bound", () => {
+ render();
+ fireEvent.click(screen.getByRole("button", { name: /工作区网盘/ }));
+ expect(screen.getByRole("status")).toHaveTextContent("qoder.mcp_binding_unsupported");
+ expect(screen.getByRole("status")).toHaveTextContent("第一回合");
+ });
+
+ it("also warns when editing an employee that already has a binding", () => {
+ render(
+ ,
+ );
+ expect(screen.getByRole("status")).toHaveTextContent("qoder.mcp_binding_unsupported");
+ });
+});
diff --git a/apps/server/src/agent-registry.ts b/apps/server/src/agent-registry.ts
index 10a90562..27636131 100644
--- a/apps/server/src/agent-registry.ts
+++ b/apps/server/src/agent-registry.ts
@@ -90,8 +90,6 @@ interface HostDefinition {
readonly capabilities: readonly string[];
}
-const HOST_ORDER = ["qoder", "claude-code", "claude-local", "codex", "codex-local", "workbuddy"] as const satisfies readonly AgentHostId[];
-
/**
* Capabilities describe the control-plane contract, not provider account
* state. No executable path, token, or raw CLI output is ever part of this
@@ -124,6 +122,10 @@ const HOST_DEFINITIONS: Readonly> = {
},
};
+/** Derived from HOST_DEFINITIONS so adding an AgentHostId is a compile error
+ * until the catalog entry exists; a handwritten subset would stay legal. */
+const HOST_ORDER = Object.keys(HOST_DEFINITIONS) as AgentHostId[];
+
const LOCAL_PROBE_FAILURES = new Set([
"timed_out",
"unsupported_version",
@@ -135,7 +137,7 @@ function isRecord(value: unknown): value is Record {
}
function isKnownHostId(value: unknown): value is AgentHostId {
- return typeof value === "string" && (HOST_ORDER as readonly string[]).includes(value);
+ return typeof value === "string" && Object.hasOwn(HOST_DEFINITIONS, value);
}
function isBoolean(value: unknown): value is boolean {
diff --git a/apps/server/test/agent-registry.test.ts b/apps/server/test/agent-registry.test.ts
index c7f6d7d9..e6186460 100644
--- a/apps/server/test/agent-registry.test.ts
+++ b/apps/server/test/agent-registry.test.ts
@@ -32,6 +32,21 @@ function health(overrides: Record = {}) {
};
}
+test("inherited Object keys are not treated as Agent Host ids", () => {
+ for (const inherited of ["constructor", "toString", "hasOwnProperty"]) {
+ assert.deepEqual(getAgentHostCapabilities({ id: inherited, engine: inherited }), []);
+ assert.equal(agentHostSupportsEmployeeMcp(inherited), false);
+ }
+});
+
+test("Host order is derived from the exhaustive catalog, not a handwritten subset", () => {
+ const hosts = listRegisteredAgentHosts(health());
+ assert.deepEqual(
+ hosts.map((host) => host.id).sort(),
+ ["claude-code", "claude-local", "codex", "codex-local", "qoder", "workbuddy"],
+ );
+});
+
test("registers every contracted Host from a health snapshot", () => {
const hosts = listRegisteredAgentHosts(health());
diff --git a/packages/ui/src/locales/en.ts b/packages/ui/src/locales/en.ts
index 8e5fe6ab..92e87684 100644
--- a/packages/ui/src/locales/en.ts
+++ b/packages/ui/src/locales/en.ts
@@ -542,6 +542,7 @@ export const enCatalog: Record = {
"hire.mcpBound": "Bound",
"hire.mcpAvailable": "Available",
"hire.mcpToolsAria": "MCP tool permissions",
+ "hire.mcpUnsupportedHint": "Every bundled engine currently rejects employee-level MCP bindings on the first turn (qoder.mcp_binding_unsupported). The grant can be saved, but the employee's first task will fail immediately until an engine supports MCP.",
"hire.capabilityPermissionHint": "Capability binding and resource access are separate; MCP tools can be turned off individually.",
"hire.actionRead": "Read",
"hire.actionCreate": "Create",
diff --git a/packages/ui/src/locales/zh.ts b/packages/ui/src/locales/zh.ts
index 5a487312..89a77221 100644
--- a/packages/ui/src/locales/zh.ts
+++ b/packages/ui/src/locales/zh.ts
@@ -545,6 +545,7 @@ export const zhCatalog: Record = {
"hire.mcpBound": "已绑定",
"hire.mcpAvailable": "可选",
"hire.mcpToolsAria": "MCP 工具权限",
+ "hire.mcpUnsupportedHint": "当前全部内置引擎都会在第一回合拒绝员工级 MCP 绑定(qoder.mcp_binding_unsupported)。绑定可以保存,但该员工的首次任务会立刻失败,直到引擎支持 MCP。",
"hire.capabilityPermissionHint": "能力绑定和资源权限分开控制;MCP 工具可以逐个关闭。",
"hire.actionRead": "查",
"hire.actionCreate": "新建",