forked from EduCraft/curriculum-project-hub
Merge pull request 'fix(hub): shipible checklist via cph_hub todo_write MCP tool' (#27) from fix/hub-mcp-todo-write into main
This commit is contained in:
@@ -12,7 +12,7 @@ export const TOOL_OPTIONS: ToolOption[] = [
|
|||||||
{ id: 'bash', label: 'Bash 命令', group: 'Shell' },
|
{ id: 'bash', label: 'Bash 命令', group: 'Shell' },
|
||||||
{ id: 'web_fetch', label: 'WebFetch', group: '网络' },
|
{ id: 'web_fetch', label: 'WebFetch', group: '网络' },
|
||||||
{ id: 'web_search', label: 'WebSearch', group: '网络' },
|
{ id: 'web_search', label: 'WebSearch', group: '网络' },
|
||||||
{ id: 'todo', label: '任务清单 TodoWrite', group: '规划' },
|
{ id: 'todo', label: '任务清单 (todo_write)', group: '规划' },
|
||||||
{ id: 'cph_check', label: 'cph check', group: 'CPH' },
|
{ id: 'cph_check', label: 'cph check', group: 'CPH' },
|
||||||
{ id: 'cph_build', label: 'cph build', group: 'CPH' },
|
{ id: 'cph_build', label: 'cph build', group: 'CPH' },
|
||||||
{ id: 'send_file', label: '发送文件(飞书)', group: '飞书' },
|
{ id: 'send_file', label: '发送文件(飞书)', group: '飞书' },
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ export const CPH_HUB_MCP_TOOL_IDS = [
|
|||||||
"pbank_search_problems",
|
"pbank_search_problems",
|
||||||
"pbank_get_problem",
|
"pbank_get_problem",
|
||||||
"pbank_get_many_problems",
|
"pbank_get_many_problems",
|
||||||
|
"todo_write",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export type CphHubMcpToolId = (typeof CPH_HUB_MCP_TOOL_IDS)[number];
|
export type CphHubMcpToolId = (typeof CPH_HUB_MCP_TOOL_IDS)[number];
|
||||||
@@ -62,6 +63,9 @@ const ROLE_TOOL_TO_CPH_HUB_MCP_TOOLS: Readonly<Record<string, readonly CphHubMcp
|
|||||||
pbank_search_problems: ["pbank_search_problems"],
|
pbank_search_problems: ["pbank_search_problems"],
|
||||||
pbank_get_problem: ["pbank_get_problem"],
|
pbank_get_problem: ["pbank_get_problem"],
|
||||||
pbank_get_many_problems: ["pbank_get_many_problems"],
|
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__send_file": ["send_file"],
|
||||||
"mcp__cph_hub__feishu_read_context": ["feishu_read_context"],
|
"mcp__cph_hub__feishu_read_context": ["feishu_read_context"],
|
||||||
"mcp__cph_hub__feishu_download_resource": ["feishu_download_resource"],
|
"mcp__cph_hub__feishu_download_resource": ["feishu_download_resource"],
|
||||||
@@ -70,6 +74,7 @@ const ROLE_TOOL_TO_CPH_HUB_MCP_TOOLS: Readonly<Record<string, readonly CphHubMcp
|
|||||||
"mcp__cph_hub__pbank_search_problems": ["pbank_search_problems"],
|
"mcp__cph_hub__pbank_search_problems": ["pbank_search_problems"],
|
||||||
"mcp__cph_hub__pbank_get_problem": ["pbank_get_problem"],
|
"mcp__cph_hub__pbank_get_problem": ["pbank_get_problem"],
|
||||||
"mcp__cph_hub__pbank_get_many_problems": ["pbank_get_many_problems"],
|
"mcp__cph_hub__pbank_get_many_problems": ["pbank_get_many_problems"],
|
||||||
|
"mcp__cph_hub__todo_write": ["todo_write"],
|
||||||
};
|
};
|
||||||
|
|
||||||
const SUPPORTED_ROLE_TOOLS = new Set([
|
const SUPPORTED_ROLE_TOOLS = new Set([
|
||||||
@@ -109,9 +114,13 @@ export function claudeSdkToolConfigForRole(
|
|||||||
export function cphHubMcpToolsForRole(
|
export function cphHubMcpToolsForRole(
|
||||||
roleTools: readonly string[] | null | undefined,
|
roleTools: readonly string[] | null | undefined,
|
||||||
): readonly CphHubMcpToolId[] {
|
): readonly CphHubMcpToolId[] {
|
||||||
if (roleTools === undefined || roleTools === null) return [...CPH_HUB_MCP_TOOL_IDS];
|
// 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[] = [];
|
const tools: CphHubMcpToolId[] = ["todo_write"];
|
||||||
for (const roleTool of roleTools) {
|
for (const roleTool of roleTools) {
|
||||||
assertSupportedRoleTool(roleTool);
|
assertSupportedRoleTool(roleTool);
|
||||||
for (const mcpTool of ROLE_TOOL_TO_CPH_HUB_MCP_TOOLS[roleTool] ?? []) {
|
for (const mcpTool of ROLE_TOOL_TO_CPH_HUB_MCP_TOOLS[roleTool] ?? []) {
|
||||||
|
|||||||
@@ -169,6 +169,7 @@ export async function runAgent(req: RunRequest): Promise<RunResult> {
|
|||||||
const allowedToolsOption = uniqueTools([
|
const allowedToolsOption = uniqueTools([
|
||||||
...toolConfig.allowedTools,
|
...toolConfig.allowedTools,
|
||||||
"TodoWrite",
|
"TodoWrite",
|
||||||
|
"mcp__cph_hub__todo_write",
|
||||||
...skillExtras,
|
...skillExtras,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@@ -14,9 +14,15 @@ export interface AgentTodoItem {
|
|||||||
|
|
||||||
const STATUSES = new Set<AgentTodoStatus>(["pending", "in_progress", "completed"]);
|
const STATUSES = new Set<AgentTodoStatus>(["pending", "in_progress", "completed"]);
|
||||||
|
|
||||||
/** True when the tool name is the SDK TodoWrite dispatcher. */
|
/** True when the tool name is SDK TodoWrite or hub mcp todo_write. */
|
||||||
export function isTodoWriteTool(toolName: string): boolean {
|
export function isTodoWriteTool(toolName: string): boolean {
|
||||||
return toolName === "TodoWrite" || toolName.endsWith("__TodoWrite");
|
const lower = toolName.toLowerCase();
|
||||||
|
return (
|
||||||
|
toolName === "TodoWrite" ||
|
||||||
|
toolName.endsWith("__TodoWrite") ||
|
||||||
|
lower === "todo_write" ||
|
||||||
|
lower.endsWith("__todo_write")
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ const TOOL_ICONS: Record<string, string> = {
|
|||||||
grep: "search-filled",
|
grep: "search-filled",
|
||||||
edit: "edit-filled",
|
edit: "edit-filled",
|
||||||
todowrite: "todo-filled",
|
todowrite: "todo-filled",
|
||||||
|
todo_write: "todo-filled",
|
||||||
send_file: "send-filled",
|
send_file: "send-filled",
|
||||||
request_approval: "thumb-up-filled",
|
request_approval: "thumb-up-filled",
|
||||||
feishu_read_context: "search-filled",
|
feishu_read_context: "search-filled",
|
||||||
@@ -52,10 +53,20 @@ const TOOL_ICONS: Record<string, string> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function toolIcon(toolName: string): string {
|
function toolIcon(toolName: string): string {
|
||||||
const normalized = toolName.toLowerCase().replace(/^mcp_/, "");
|
const normalized = toolName.toLowerCase().replace(/^mcp_/, "").replace(/^cph_hub__/, "");
|
||||||
return TOOL_ICONS[normalized] ?? "tool-filled";
|
return TOOL_ICONS[normalized] ?? "tool-filled";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isTodoToolName(toolName: string): boolean {
|
||||||
|
const lower = toolName.toLowerCase();
|
||||||
|
return (
|
||||||
|
lower === "todowrite" ||
|
||||||
|
lower === "todo_write" ||
|
||||||
|
lower.endsWith("__todo_write") ||
|
||||||
|
lower.endsWith("__todowrite")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Card builder
|
// Card builder
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -80,8 +91,8 @@ export function buildAgentCard(params: {
|
|||||||
elements.push(buildTodoPanel(todos, phase !== "complete"));
|
elements.push(buildTodoPanel(todos, phase !== "complete"));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tool-use panel (exclude TodoWrite itself — already shown as checklist)
|
// Tool-use panel (exclude todo tools — already shown as checklist)
|
||||||
const visibleToolSteps = toolUseSteps.filter((step) => step.toolName.toLowerCase() !== "todowrite");
|
const visibleToolSteps = toolUseSteps.filter((step) => !isTodoToolName(step.toolName));
|
||||||
if (visibleToolSteps.length > 0) {
|
if (visibleToolSteps.length > 0) {
|
||||||
elements.push(buildToolUsePanel(visibleToolSteps, toolUseElapsedMs, phase !== "complete"));
|
elements.push(buildToolUsePanel(visibleToolSteps, toolUseElapsedMs, phase !== "complete"));
|
||||||
} else if (
|
} else if (
|
||||||
|
|||||||
@@ -399,6 +399,50 @@ export function createFileDeliveryMcpServer(options: FileDeliveryToolOptions): M
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (enabledTools.has("todo_write")) {
|
||||||
|
tools.push(
|
||||||
|
tool(
|
||||||
|
"todo_write",
|
||||||
|
"Create or replace the shared task checklist for this run. Call this first when the user`s request has multiple steps, then again whenever progress changes. Each item needs content + status (pending|in_progress|completed). Optionally set activeForm (present-tense label) for the current in_progress item. Keep exactly one item in_progress when work is underway. Hub shows this list on the Feishu card so teachers can track progress.",
|
||||||
|
{
|
||||||
|
todos: z
|
||||||
|
.array(
|
||||||
|
z.object({
|
||||||
|
content: z.string().min(1).describe("Imperative task description, e.g. Search PBank for derivatives."),
|
||||||
|
status: z
|
||||||
|
.enum(["pending", "in_progress", "completed"])
|
||||||
|
.describe("pending | in_progress | completed"),
|
||||||
|
activeForm: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe("Present continuous label while in_progress, e.g. Searching PBank."),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.min(1)
|
||||||
|
.max(32)
|
||||||
|
.describe("Full replacement list for the checklist (not a patch)."),
|
||||||
|
},
|
||||||
|
async (args) => {
|
||||||
|
const completed = args.todos.filter((t) => t.status === "completed").length;
|
||||||
|
const inProgress = args.todos.filter((t) => t.status === "in_progress").length;
|
||||||
|
const lines = args.todos.map((t, i) => {
|
||||||
|
const mark = t.status === "completed" ? "x" : t.status === "in_progress" ? ">" : " ";
|
||||||
|
const label = t.status === "in_progress" && t.activeForm ? t.activeForm : t.content;
|
||||||
|
return `${i + 1}. [${mark}] ${label}`;
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: `Checklist updated ${completed}/${args.todos.length} completed, ${inProgress} in progress.\n${lines.join("\n")}`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
{ alwaysLoad: true },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const instructions = mcpInstructions(enabledTools);
|
const instructions = mcpInstructions(enabledTools);
|
||||||
return createSdkMcpServer({
|
return createSdkMcpServer({
|
||||||
@@ -507,6 +551,12 @@ function mcpInstructions(enabledTools: ReadonlySet<CphHubMcpToolId>): string {
|
|||||||
"If tools fail because no ACTIVE pbank capability connection exists, tell the user an org admin must configure 题库 on the admin capabilities page.",
|
"If tools fail because no ACTIVE pbank capability connection exists, tell the user an org admin must configure 题库 on the admin capabilities page.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (enabledTools.has("todo_write")) {
|
||||||
|
instructions.push(
|
||||||
|
"For multi-step work, call todo_write first with a full checklist, then update it as each step starts/finishes so the teacher sees live progress on the card.",
|
||||||
|
"Prefer mcp__cph_hub__todo_write (todo_write) for progress tracking — do not skip it because built-in TodoWrite is absent.",
|
||||||
|
);
|
||||||
|
}
|
||||||
instructions.push(
|
instructions.push(
|
||||||
"Role skill docs (when bound) are readable at .cph/runtime-skills/<skill-name>/SKILL.md or $CPH_RUNTIME_SKILLS_DIR/<skill-name>/SKILL.md. Prefer the Skill tool when available. Workspace .claude/ and .mcp.json are sandbox stubs — not skill or MCP source.",
|
"Role skill docs (when bound) are readable at .cph/runtime-skills/<skill-name>/SKILL.md or $CPH_RUNTIME_SKILLS_DIR/<skill-name>/SKILL.md. Prefer the Skill tool when available. Workspace .claude/ and .mcp.json are sandbox stubs — not skill or MCP source.",
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -12,7 +12,10 @@ const itOnLinux = process.platform === "linux" ? it : it.skip;
|
|||||||
describe("Feishu message resource download", () => {
|
describe("Feishu message resource download", () => {
|
||||||
it("exposes the download tool to default and explicitly configured roles", () => {
|
it("exposes the download tool to default and explicitly configured roles", () => {
|
||||||
expect(cphHubMcpToolsForRole(undefined)).toContain("feishu_download_resource");
|
expect(cphHubMcpToolsForRole(undefined)).toContain("feishu_download_resource");
|
||||||
expect(cphHubMcpToolsForRole(["feishu_download_resource"])).toEqual(["feishu_download_resource"]);
|
expect(cphHubMcpToolsForRole(["feishu_download_resource"])).toEqual([
|
||||||
|
"todo_write",
|
||||||
|
"feishu_download_resource",
|
||||||
|
]);
|
||||||
expect(claudeSdkToolConfigForRole(["feishu_download_resource"]).allowedTools).toEqual([
|
expect(claudeSdkToolConfigForRole(["feishu_download_resource"]).allowedTools).toEqual([
|
||||||
"mcp__cph_hub__feishu_download_resource",
|
"mcp__cph_hub__feishu_download_resource",
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -186,7 +186,13 @@ describe("runAgent", () => {
|
|||||||
expect(queryMock.mock.calls[0]?.[0]).toMatchObject({
|
expect(queryMock.mock.calls[0]?.[0]).toMatchObject({
|
||||||
options: {
|
options: {
|
||||||
tools: ["Read", "Bash", "TodoWrite"],
|
tools: ["Read", "Bash", "TodoWrite"],
|
||||||
allowedTools: ["Read", "Bash", "mcp__cph_hub__send_file", "TodoWrite"],
|
allowedTools: [
|
||||||
|
"Read",
|
||||||
|
"Bash",
|
||||||
|
"mcp__cph_hub__send_file",
|
||||||
|
"TodoWrite",
|
||||||
|
"mcp__cph_hub__todo_write",
|
||||||
|
],
|
||||||
settings: expect.objectContaining({ todoFeatureEnabled: true }),
|
settings: expect.objectContaining({ todoFeatureEnabled: true }),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -209,7 +215,7 @@ describe("runAgent", () => {
|
|||||||
expect(queryMock.mock.calls[0]?.[0]).toMatchObject({
|
expect(queryMock.mock.calls[0]?.[0]).toMatchObject({
|
||||||
options: {
|
options: {
|
||||||
tools: ["TodoWrite"],
|
tools: ["TodoWrite"],
|
||||||
allowedTools: ["TodoWrite"],
|
allowedTools: ["TodoWrite", "mcp__cph_hub__todo_write"],
|
||||||
settings: expect.objectContaining({ todoFeatureEnabled: true }),
|
settings: expect.objectContaining({ todoFeatureEnabled: true }),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -239,6 +245,7 @@ describe("runAgent", () => {
|
|||||||
expect(queryMock.mock.calls[0]?.[0]).toMatchObject({
|
expect(queryMock.mock.calls[0]?.[0]).toMatchObject({
|
||||||
options: {
|
options: {
|
||||||
tools: ["TodoWrite", "Skill"],
|
tools: ["TodoWrite", "Skill"],
|
||||||
|
allowedTools: expect.arrayContaining(["TodoWrite", "mcp__cph_hub__todo_write", "Skill"]),
|
||||||
plugins: [expect.objectContaining({ type: "local", skipMcpDiscovery: true })],
|
plugins: [expect.objectContaining({ type: "local", skipMcpDiscovery: true })],
|
||||||
skills: ["cph-runtime:typst"],
|
skills: ["cph-runtime:typst"],
|
||||||
settings: expect.objectContaining({ todoFeatureEnabled: true }),
|
settings: expect.objectContaining({ todoFeatureEnabled: true }),
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ describe("todo list parse", () => {
|
|||||||
|
|
||||||
it("recognizes TodoWrite tool names", () => {
|
it("recognizes TodoWrite tool names", () => {
|
||||||
expect(isTodoWriteTool("TodoWrite")).toBe(true);
|
expect(isTodoWriteTool("TodoWrite")).toBe(true);
|
||||||
|
expect(isTodoWriteTool("mcp__cph_hub__todo_write")).toBe(true);
|
||||||
expect(isTodoWriteTool("Bash")).toBe(false);
|
expect(isTodoWriteTool("Bash")).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user