Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
107 changes: 107 additions & 0 deletions adapters/project/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>): Promise<{ content: { type: string; text: string }[]; isError?: boolean }> {
if (name === 'todowrite') {
Expand Down Expand Up @@ -63,6 +123,53 @@ async function dispatch(name: string, args: Record<string, unknown>): 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}`);
}

Expand Down
2 changes: 1 addition & 1 deletion java/src/main/java/dev/sorted/mcphub/ProviderManager.java
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ public static List<GroupConfig> 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,
Expand Down
87 changes: 87 additions & 0 deletions java/src/main/resources/capabilities.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
10 changes: 5 additions & 5 deletions java/src/test/java/dev/sorted/mcphub/CapabilityRegistryTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -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());
}

// -----------------------------------------------------------------------
Expand Down
12 changes: 6 additions & 6 deletions java/src/test/java/dev/sorted/mcphub/McpHandlerTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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();
Expand Down
Loading