forked from EduCraft/curriculum-project-hub
e3b463d390
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.
166 lines
5.3 KiB
TypeScript
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);
|
|
}
|