diff --git a/adapters/project/index.ts b/adapters/project/index.ts index 3b96784..f27e8d8 100644 --- a/adapters/project/index.ts +++ b/adapters/project/index.ts @@ -23,10 +23,70 @@ const TOOLS = [ name: 'lsp', description: 'Query Language Server Protocol information (go-to-definition, hover, references). Read-only.', inputSchema: { type: 'object', properties: { command: { type: 'string', description: 'LSP command (definition, references, hover)' }, file: { type: 'string', description: 'File path' }, line: { type: 'number', description: 'Line number (1-indexed)' }, character: { type: 'number', description: 'Character offset' } }, required: ['command', 'file'] } + }, + { + name: 'task_create', + description: 'Create a persistent task for cross-session tracking. Modifies local state.', + inputSchema: { type: 'object', properties: { title: { type: 'string', description: 'Task title' }, description: { type: 'string', description: 'Task description or details' }, priority: { type: 'string', enum: ['HIGH', 'MEDIUM', 'LOW'], default: 'MEDIUM' } }, required: ['title'] } + }, + { + name: 'task_list', + description: 'List persistent tasks with optional status filter. Read-only.', + inputSchema: { type: 'object', properties: { status: { type: 'string', enum: ['open', 'in_progress', 'done', 'all'], default: 'all', description: 'Filter by status' } }, required: [] } + }, + { + name: 'task_update', + description: 'Update task status (e.g. mark as done for 消込) with optional note. Modifies local state.', + inputSchema: { type: 'object', properties: { id: { type: 'string', description: 'Task ID from task_list' }, status: { type: 'string', enum: ['open', 'in_progress', 'done'], description: 'New status' }, note: { type: 'string', description: 'Optional note to record with this update' } }, required: ['id', 'status'] } + }, + { + name: 'task_delete', + description: 'Delete a task permanently. Modifies local state.', + inputSchema: { type: 'object', properties: { id: { type: 'string', description: 'Task ID from task_list' } }, required: ['id'] } } ]; const TODO_FILE = process.env['MCPHUB_TODO_FILE'] || path.join(process.env['HOME'] || '/tmp', '.mcphub_todos.json'); +const TASKS_FILE = path.join(process.env['HOME'] || '/tmp', '.config', 'mcphub', 'tasks.json'); + +interface Task { + id: string; + title: string; + description: string; + status: string; + priority: string; + createdAt: string; + updatedAt: string; + notes: string[]; +} + +function loadTasks(): Task[] { + try { + if (fs.existsSync(TASKS_FILE)) { + return JSON.parse(fs.readFileSync(TASKS_FILE, 'utf-8')); + } + } catch { /* corrupted file, start fresh */ } + return []; +} + +function saveTasks(tasks: Task[]): void { + const dir = path.dirname(TASKS_FILE); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(TASKS_FILE, JSON.stringify(tasks, null, 2), 'utf-8'); +} + +function nextTaskId(tasks: Task[]): string { + let max = 0; + for (const t of tasks) { + const m = t.id.match(/^task_(\d+)$/); + if (m) { const n = parseInt(m[1], 10); if (n > max) max = n; } + } + return `task_${String(max + 1).padStart(3, '0')}`; +} + +function now(): string { + return new Date().toISOString(); +} async function dispatch(name: string, args: Record): Promise<{ content: { type: string; text: string }[]; isError?: boolean }> { if (name === 'todowrite') { @@ -63,6 +123,53 @@ async function dispatch(name: string, args: Record): Promise<{ const file = args['file'] as string; return { content: [{ type: 'text', text: `LSP ${command} for ${file}: LSP subprocess integration is alpha+ scope. Tool registered and callable; full language server protocol requires Session 4.` }] }; } + if (name === 'task_create') { + const tasks = loadTasks(); + const task: Task = { + id: nextTaskId(tasks), + title: (args['title'] as string)?.trim() || 'Untitled', + description: (args['description'] as string)?.trim() || '', + status: 'open', + priority: (args['priority'] as string) || 'MEDIUM', + createdAt: now(), + updatedAt: now(), + notes: [] + }; + tasks.push(task); + saveTasks(tasks); + return { content: [{ type: 'text', text: JSON.stringify(task) }] }; + } + if (name === 'task_list') { + const tasks = loadTasks(); + const filter = (args['status'] as string) || 'all'; + const filtered = filter === 'all' ? tasks : tasks.filter(t => t.status === filter); + const open = tasks.filter(t => t.status === 'open').length; + const progress = tasks.filter(t => t.status === 'in_progress').length; + const done = tasks.filter(t => t.status === 'done').length; + return { content: [{ type: 'text', text: JSON.stringify({ tasks: filtered, counts: { open, in_progress: progress, done, total: tasks.length }, filter }) }] }; + } + if (name === 'task_update') { + const tasks = loadTasks(); + const id = (args['id'] as string)?.trim(); + const status = (args['status'] as string)?.trim(); + const note = (args['note'] as string)?.trim(); + const idx = tasks.findIndex(t => t.id === id); + if (idx === -1) throw new Error(`Task not found: ${id}`); + tasks[idx].status = status; + tasks[idx].updatedAt = now(); + if (note) tasks[idx].notes.push(`[${now()}] ${note}`); + saveTasks(tasks); + return { content: [{ type: 'text', text: JSON.stringify(tasks[idx]) }] }; + } + if (name === 'task_delete') { + const tasks = loadTasks(); + const id = (args['id'] as string)?.trim(); + const before = tasks.length; + const remaining = tasks.filter(t => t.id !== id); + if (remaining.length === before) throw new Error(`Task not found: ${id}`); + saveTasks(remaining); + return { content: [{ type: 'text', text: JSON.stringify({ deleted: id, remaining: remaining.length }) }] }; + } throw new Error(`Unknown tool: ${name}`); } diff --git a/java/src/main/java/dev/sorted/mcphub/ProviderManager.java b/java/src/main/java/dev/sorted/mcphub/ProviderManager.java index 8e31387..40964e9 100644 --- a/java/src/main/java/dev/sorted/mcphub/ProviderManager.java +++ b/java/src/main/java/dev/sorted/mcphub/ProviderManager.java @@ -87,7 +87,7 @@ public static List defaultGroups() { new GroupConfig("edit", "edit/index.js", null, new String[]{"apply_patch"}, "builtin_hosted"), new GroupConfig("project", "project/index.js", null, - new String[]{"todowrite", "list", "codesearch", "lsp"}, "builtin_hosted"), + new String[]{"todowrite", "list", "codesearch", "lsp", "task_create", "task_list", "task_update", "task_delete"}, "builtin_hosted"), new GroupConfig("session", "session/index.js", null, new String[]{"plan_enter", "plan_exit", "skill", "batch"}, "builtin_hosted"), new GroupConfig("synthetic", "synthetic/index.js", null, diff --git a/java/src/main/resources/capabilities.yaml b/java/src/main/resources/capabilities.yaml index 5c5803e..59bb9fa 100644 --- a/java/src/main/resources/capabilities.yaml +++ b/java/src/main/resources/capabilities.yaml @@ -355,6 +355,93 @@ capabilities: side_effect_class: "local_state" timeout_hint_ms: 60000 + - capability_id: "task_create" + display_name: "task_create" + provider_id: "builtin-hatch" + access_class: "guarded" + rw_boundary: "write" + enabled: true + priority: 10 + schema: + type: object + properties: + title: { type: string, description: "Task title" } + description: { type: string, description: "Task description or details" } + priority: { type: string, enum: [HIGH, MEDIUM, LOW], default: MEDIUM } + required: [title] + contract: + purpose: "Create a persistent task for cross-session tracking." + may_do: ["Write tasks to ~/.config/mcphub/tasks.json"] + must_not_do: ["Modify other files"] + when_to_call: ["A new actionable item is discovered that should survive /clear"] + when_not_to_call: ["Volatile in-session reminders; use todowrite"] + side_effect_class: "local_state" + timeout_hint_ms: 5000 + + - capability_id: "task_list" + display_name: "task_list" + provider_id: "builtin-hatch" + access_class: "safe" + rw_boundary: "read" + enabled: true + priority: 10 + schema: + type: object + properties: + status: { type: string, enum: [open, in_progress, done, all], default: all } + contract: + purpose: "List persistent tasks with optional status filter." + may_do: ["Read ~/.config/mcphub/tasks.json"] + must_not_do: ["Modify any files"] + when_to_call: ["Session start: review remaining work from previous sessions"] + when_not_to_call: ["Creating or updating tasks"] + side_effect_class: "none" + timeout_hint_ms: 5000 + + - capability_id: "task_update" + display_name: "task_update" + provider_id: "builtin-hatch" + access_class: "guarded" + rw_boundary: "write" + enabled: true + priority: 10 + schema: + type: object + properties: + id: { type: string, description: "Task ID from task_list" } + status: { type: string, enum: [open, in_progress, done], description: "New status (done = 消込)" } + note: { type: string, description: "Optional note" } + required: [id, status] + contract: + purpose: "Update task status. done = 消込." + may_do: ["Modify tasks.json"] + must_not_do: ["Delete tasks (use task_delete)"] + when_to_call: ["Task completed — mark as done for 消込"] + when_not_to_call: ["Creating new tasks"] + side_effect_class: "local_state" + timeout_hint_ms: 5000 + + - capability_id: "task_delete" + display_name: "task_delete" + provider_id: "builtin-hatch" + access_class: "guarded" + rw_boundary: "write" + enabled: true + priority: 10 + schema: + type: object + properties: + id: { type: string, description: "Task ID from task_list" } + required: [id] + contract: + purpose: "Delete a task permanently." + may_do: ["Remove task from tasks.json"] + must_not_do: ["Delete non-task files"] + when_to_call: ["Task is no longer relevant"] + when_not_to_call: ["Task is done — use task_update status=done instead"] + side_effect_class: "local_state" + timeout_hint_ms: 5000 + # ----------------------------------------------------------------------- # Relay providers: configure in ~/.config/mcphub/relays.yaml # See README.md for format and examples. diff --git a/java/src/test/java/dev/sorted/mcphub/CapabilityRegistryTest.java b/java/src/test/java/dev/sorted/mcphub/CapabilityRegistryTest.java index 0d0e32c..949f504 100644 --- a/java/src/test/java/dev/sorted/mcphub/CapabilityRegistryTest.java +++ b/java/src/test/java/dev/sorted/mcphub/CapabilityRegistryTest.java @@ -25,20 +25,20 @@ void setUp() { } // ----------------------------------------------------------------------- - // Test 1: load() with embedded capabilities.yaml — 11 entries, 0 rejected - // (11 builtin-hosted). synthetic_delay is a TEST FIXTURE, + // Test 1: load() with embedded capabilities.yaml — 15 entries, 0 rejected + // (15 builtin-hosted + task). synthetic_delay is a TEST FIXTURE, // kept out of production capabilities.yaml — loaded via MCPHUB_TEST_FIXTURE. // ----------------------------------------------------------------------- @Test - void loadEmbeddedYaml_11EntriesLoaded_0Rejected() throws Exception { + void loadEmbeddedYaml_15EntriesLoaded_0Rejected() throws Exception { try (InputStream is = getClass().getClassLoader().getResourceAsStream("capabilities.yaml")) { assertNotNull(is, "capabilities.yaml must be on classpath"); registry.load(is); } - assertEquals(11, registry.getLoadedCount(), "Expected 11 capabilities (builtin-hosted)"); + assertEquals(15, registry.getLoadedCount(), "Expected 15 capabilities (builtin-hosted + task)"); assertEquals(0, registry.getRejectedCount(), "Expected 0 rejected entries"); - assertEquals(11, registry.getAll().size()); + assertEquals(15, registry.getAll().size()); } // ----------------------------------------------------------------------- diff --git a/java/src/test/java/dev/sorted/mcphub/McpHandlerTest.java b/java/src/test/java/dev/sorted/mcphub/McpHandlerTest.java index 3018197..266bbf0 100644 --- a/java/src/test/java/dev/sorted/mcphub/McpHandlerTest.java +++ b/java/src/test/java/dev/sorted/mcphub/McpHandlerTest.java @@ -84,13 +84,13 @@ void toolsList_whenOpen_returns11PlusDisambiguation() throws Exception { sm.transition(StateMachine.Trigger.OPEN, "s1"); confirmGroup("web", "webfetch", "websearch"); confirmGroup("edit", "apply_patch"); - confirmGroup("project", "todowrite", "list", "codesearch", "lsp"); + confirmGroup("project", "todowrite", "list", "codesearch", "lsp", "task_create", "task_list", "task_update", "task_delete"); confirmGroup("session", "plan_enter", "plan_exit", "skill", "batch"); JsonNode r = handler.handle("tools/list", null); JsonNode tools = r.path("tools"); - // 11 registered tools (builtin-hosted) + 1 disambiguation = 12 - assertEquals(12, tools.size(), "Expected 11 capabilities + 1 disambiguation tool"); + // 15 registered tools (builtin-hosted + task) + 1 disambiguation = 16 + assertEquals(16, tools.size(), "Expected 15 capabilities + 1 disambiguation tool"); // Verify disambiguation tool is present boolean hasDisambig = false; @@ -118,7 +118,7 @@ void toolsList_deniedTool_notVisible() throws Exception { sm.transition(StateMachine.Trigger.OPEN, "s1"); confirmGroup("web", "webfetch", "websearch"); confirmGroup("edit", "apply_patch"); - confirmGroup("project", "todowrite", "list", "codesearch", "lsp"); + confirmGroup("project", "todowrite", "list", "codesearch", "lsp", "task_create", "task_list", "task_update", "task_delete"); confirmGroup("session", "plan_enter", "plan_exit", "skill", "batch"); JsonNode r = handler.handle("tools/list", null); @@ -149,7 +149,7 @@ void toolsCall_unknownTool_returnsToolNotFound() throws Exception { sm.transition(StateMachine.Trigger.OPEN, "s1"); confirmGroup("web", "webfetch", "websearch"); confirmGroup("edit", "apply_patch"); - confirmGroup("project", "todowrite", "list", "codesearch", "lsp"); + confirmGroup("project", "todowrite", "list", "codesearch", "lsp", "task_create", "task_list", "task_update", "task_delete"); confirmGroup("session", "plan_enter", "plan_exit", "skill", "batch"); ObjectNode params = mapper.createObjectNode(); @@ -220,7 +220,7 @@ void toolsCall_disambiguate_returnsRecommendation() throws Exception { sm.transition(StateMachine.Trigger.OPEN, "s1"); confirmGroup("web", "webfetch", "websearch"); confirmGroup("edit", "apply_patch"); - confirmGroup("project", "todowrite", "list", "codesearch", "lsp"); + confirmGroup("project", "todowrite", "list", "codesearch", "lsp", "task_create", "task_list", "task_update", "task_delete"); confirmGroup("session", "plan_enter", "plan_exit", "skill", "batch"); ObjectNode params = mapper.createObjectNode();