Files
curriculum-project-hub/hub/src/agent/roleTools.ts
T
hongjr03 e3b463d390 fix(hub): ship checklist via cph_hub todo_write MCP tool
Native Claude TodoWrite is not registered in headless agent mode even with
--tools default. Add mcp__cph_hub__todo_write (always enabled), mirror the
TodoWrite schema, instruct multi-step runs to use it, and keep the Feishu
progress panel parsing both native and hub tool names.
2026-07-23 23:08:42 +08:00

166 lines
5.3 KiB
TypeScript

export const DEFAULT_CLAUDE_BUILT_IN_TOOLS = [
"Read",
"Write",
"Bash",
"Glob",
"Grep",
"WebFetch",
"WebSearch",
"TodoWrite",
] as const;
export const CPH_HUB_MCP_SERVER_NAME = "cph_hub";
export const CPH_HUB_MCP_TOOL_IDS = [
"send_file",
"feishu_read_context",
"feishu_download_resource",
"request_approval",
"convert_pdf_to_md",
"pbank_search_problems",
"pbank_get_problem",
"pbank_get_many_problems",
"todo_write",
] as const;
export type CphHubMcpToolId = (typeof CPH_HUB_MCP_TOOL_IDS)[number];
export interface ClaudeSdkToolConfig {
readonly tools: readonly string[];
readonly allowedTools: readonly string[];
}
const ROLE_TOOL_TO_CLAUDE_BUILT_INS: Readonly<Record<string, readonly string[]>> = {
read_file: ["Read"],
write_file: ["Write"],
list_files: ["Glob"],
search_files: ["Grep"],
bash: ["Bash"],
// ADR-0017 replaced cph custom tools with Bash commands. Granting either
// cph role tool therefore exposes the SDK Bash tool; cph-only Bash narrowing
// would need a separate command-policy layer.
cph_check: ["Bash"],
cph_build: ["Bash"],
web_fetch: ["WebFetch"],
web_search: ["WebSearch"],
todo: ["TodoWrite"],
TodoWrite: ["TodoWrite"],
Read: ["Read"],
Write: ["Write"],
Bash: ["Bash"],
Glob: ["Glob"],
Grep: ["Grep"],
WebFetch: ["WebFetch"],
WebSearch: ["WebSearch"],
};
const ROLE_TOOL_TO_CPH_HUB_MCP_TOOLS: Readonly<Record<string, readonly CphHubMcpToolId[]>> = {
send_file: ["send_file"],
feishu_read_context: ["feishu_read_context"],
feishu_download_resource: ["feishu_download_resource"],
request_approval: ["request_approval"],
convert_pdf_to_md: ["convert_pdf_to_md"],
pbank: ["pbank_search_problems", "pbank_get_problem", "pbank_get_many_problems"],
pbank_search_problems: ["pbank_search_problems"],
pbank_get_problem: ["pbank_get_problem"],
pbank_get_many_problems: ["pbank_get_many_problems"],
todo: ["todo_write"],
TodoWrite: ["todo_write"],
todo_write: ["todo_write"],
"mcp__cph_hub__send_file": ["send_file"],
"mcp__cph_hub__feishu_read_context": ["feishu_read_context"],
"mcp__cph_hub__feishu_download_resource": ["feishu_download_resource"],
"mcp__cph_hub__request_approval": ["request_approval"],
"mcp__cph_hub__convert_pdf_to_md": ["convert_pdf_to_md"],
"mcp__cph_hub__pbank_search_problems": ["pbank_search_problems"],
"mcp__cph_hub__pbank_get_problem": ["pbank_get_problem"],
"mcp__cph_hub__pbank_get_many_problems": ["pbank_get_many_problems"],
"mcp__cph_hub__todo_write": ["todo_write"],
};
const SUPPORTED_ROLE_TOOLS = new Set([
...Object.keys(ROLE_TOOL_TO_CLAUDE_BUILT_INS),
...Object.keys(ROLE_TOOL_TO_CPH_HUB_MCP_TOOLS),
]);
export function claudeSdkToolConfigForRole(
roleTools: readonly string[] | null | undefined,
): ClaudeSdkToolConfig {
// DB/runtime "unrestricted" is JSON null; treat the same as undefined.
if (roleTools === undefined || roleTools === null) {
const mcpTools = CPH_HUB_MCP_TOOL_IDS.map(claudeMcpToolName);
return {
tools: [...DEFAULT_CLAUDE_BUILT_IN_TOOLS],
allowedTools: [...DEFAULT_CLAUDE_BUILT_IN_TOOLS, ...mcpTools],
};
}
const builtIns: string[] = [];
const allowedTools: string[] = [];
for (const roleTool of roleTools) {
assertSupportedRoleTool(roleTool);
for (const tool of ROLE_TOOL_TO_CLAUDE_BUILT_INS[roleTool] ?? []) {
pushUnique(builtIns, tool);
pushUnique(allowedTools, tool);
}
for (const mcpTool of ROLE_TOOL_TO_CPH_HUB_MCP_TOOLS[roleTool] ?? []) {
pushUnique(allowedTools, claudeMcpToolName(mcpTool));
}
}
return { tools: builtIns, allowedTools };
}
export function cphHubMcpToolsForRole(
roleTools: readonly string[] | null | undefined,
): readonly CphHubMcpToolId[] {
// Always expose hub-side todo_write so progress cards work even when the
// native Claude TodoWrite tool is not registered in headless agent mode.
if (roleTools === undefined || roleTools === null) {
return [...CPH_HUB_MCP_TOOL_IDS];
}
const tools: CphHubMcpToolId[] = ["todo_write"];
for (const roleTool of roleTools) {
assertSupportedRoleTool(roleTool);
for (const mcpTool of ROLE_TOOL_TO_CPH_HUB_MCP_TOOLS[roleTool] ?? []) {
pushUnique(tools, mcpTool);
}
}
return tools;
}
export function roleToolsAllow(
roleTools: readonly string[] | null | undefined,
roleTool: string,
): boolean {
if (roleTools === undefined || roleTools === null) return true;
for (const configured of roleTools) {
assertSupportedRoleTool(configured);
if (configured === roleTool) return true;
const mapped = ROLE_TOOL_TO_CPH_HUB_MCP_TOOLS[configured];
if (mapped !== undefined && mapped.includes(roleTool as CphHubMcpToolId)) return true;
// Umbrella: role tool "pbank" allows any pbank_* MCP or role tool.
if (configured === "pbank" && roleTool.startsWith("pbank")) return true;
}
return false;
}
export function assertSupportedRoleTools(roleTools: readonly string[]): void {
for (const roleTool of roleTools) assertSupportedRoleTool(roleTool);
}
function claudeMcpToolName(tool: CphHubMcpToolId): string {
return `mcp__${CPH_HUB_MCP_SERVER_NAME}__${tool}`;
}
function assertSupportedRoleTool(roleTool: string): void {
if (!SUPPORTED_ROLE_TOOLS.has(roleTool)) {
throw new Error(`unknown role tool id: ${roleTool}`);
}
}
function pushUnique<T>(items: T[], item: T): void {
if (!items.includes(item)) items.push(item);
}