feat(mcp): MCP server — agenthub hub tools over stdio

Solid-collaboration foundation: agents invoke STRUCTURED tools instead of
shelling out to the CLI — so a tool can't be narrated away, hallucinated as
"doesn't exist", or mistyped. Confirmed working for Claude / Codex / Kimi
(all speak MCP).

- src/mcp/server.ts: `agenthub mcp` starts a stdio MCP server exposing 16
  tools (work, task_list/show/create/assign/claim/review/reopen/done,
  memory_add/search, handoff_read/create, decision_create, hello, status).
  Thin layer over the SAME core: proxies through the REST API (remoteClient)
  when a hub is configured — so board, SSE, status auto-refresh and CLI keep
  working unchanged — else local services. agenthub_work blocks on SSE until
  a task addressed to the agent is claimable, then returns it + its handoff.
- `agenthub mcp` command; excluded from the update-notify (stdio purity).
- Verified: initialize + tools/list (16) + tools/call (status, task_list)
  proxy to the live hub.

Does NOT change the dormant-agent reality (agents still loop agenthub_work);
it makes every interaction reliable. Bump 0.6.1 -> 0.7.0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
chahinebrini 2026-06-29 00:52:13 +02:00
parent 986639a5f3
commit 5586f2c15b
5 changed files with 878 additions and 3 deletions

View File

@ -1,6 +1,6 @@
{
"name": "agenthub",
"version": "0.6.1",
"version": "0.7.0",
"description": "Local coordination layer for AI coding agents",
"type": "module",
"main": "./dist/index.js",
@ -23,6 +23,7 @@
},
"dependencies": {
"@inquirer/prompts": "^7.0.0",
"@modelcontextprotocol/sdk": "^1.29.0",
"better-sqlite3": "^12.0.0",
"commander": "^13.0.0",
"fastify": "^5.0.0",

671
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -11,6 +11,7 @@ import { update } from './commands/update.js';
import { watchEvents } from './commands/watch.js';
import { startAgent } from './commands/start.js';
import { workAgent } from './commands/work.js';
import { startMcpServer } from '../mcp/server.js';
import { loadConfig, saveConfig } from '../core/config.js';
import { findProjectRoot } from '../core/paths.js';
import { discoverServer } from '../discovery.js';
@ -114,7 +115,7 @@ async function runRemote(serverUrl: string, fn: () => Promise<void>): Promise<vo
export function createProgram(cwd: string): Command {
const program = new Command('agenthub')
.description('Local coordination layer for AI coding agents')
.version('0.6.1')
.version('0.7.0')
.option('--server <url>', 'AgentHub server URL (env: AGENTHUB_SERVER)');
program
@ -481,6 +482,14 @@ export function createProgram(cwd: string): Command {
await update();
});
// ─── mcp ───────────────────────────────────────────────────────────────────
program
.command('mcp')
.description('Start the MCP server (stdio) — exposes hub tools to MCP-capable agents (Claude/Codex/Kimi)')
.action(async () => {
await startMcpServer(cwd);
});
// ─── hello (presence) ──────────────────────────────────────────────────────
program
.command('hello')

View File

@ -2,7 +2,7 @@ import { createProgram } from './cli/index.js';
import { maybeNotifyUpdate } from './cli/commands/update.js';
const argv = process.argv.slice(2);
if (!argv.includes('update') && !argv.includes('server')) {
if (!argv.includes('update') && !argv.includes('server') && !argv.includes('mcp')) {
maybeNotifyUpdate();
}

194
src/mcp/server.ts Normal file
View File

@ -0,0 +1,194 @@
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { z } from 'zod';
import { findProjectRoot } from '../core/paths.js';
import { loadConfig } from '../core/config.js';
import { remoteClient } from '../cli/remoteClient.js';
import { parseSSEBuffer } from '../cli/commands/watch.js';
import { findAddressedOpenTask, type AgentContext } from '../cli/commands/start.js';
import {
listTasks,
getTask,
createTask,
claimTask,
assignTask,
reviewTask,
reopenTask,
doneTask,
} from '../core/services/taskService.js';
import { createHandoff, getHandoff } from '../core/services/handoffService.js';
import { addMemory, searchMemory } from '../core/services/memoryService.js';
import { createDecision } from '../core/services/decisionService.js';
import { getStatus } from '../core/services/statusService.js';
/**
* AgentHub MCP server (TSK-0030).
*
* Exposes the hub operations as structured MCP tools instead of CLI strings
* so an agent invokes a typed tool (it can't narrate it away, hallucinate a
* missing command, or get the syntax wrong). It's a thin layer over the SAME
* core: when a hub server is configured/reachable it proxies through the REST
* API (remoteClient) so the board, SSE stream, status auto-refresh and CLI
* all keep working unchanged otherwise it falls back to the local services.
*
* Transport: stdio (each agent's CLI spawns `agenthub mcp` as a subprocess).
*/
function resolveContext(cwd: string): { root: string; serverUrl?: string } {
const root = findProjectRoot(cwd) ?? cwd;
let serverUrl = process.env.AGENTHUB_SERVER || undefined;
if (!serverUrl) {
try {
serverUrl = loadConfig(root).serverUrl;
} catch {
// no project config — local/none
}
}
return { root, serverUrl };
}
function asText(value: unknown) {
return { content: [{ type: 'text' as const, text: typeof value === 'string' ? value : JSON.stringify(value, null, 2) }] };
}
/** Block on the SSE stream until findClaim() returns a task, or timeout. */
function waitForTask<T>(serverUrl: string, findClaim: () => Promise<T | null>, timeoutSec: number): Promise<T | null> {
return new Promise((resolve) => {
const controller = new AbortController();
let settled = false;
const finish = (v: T | null) => {
if (settled) return;
settled = true;
try { controller.abort(); } catch { /* already */ }
resolve(v);
};
const timer = setTimeout(() => finish(null), Math.max(1, timeoutSec) * 1000);
fetch(new URL('/events', serverUrl).toString(), { signal: controller.signal, headers: { Accept: 'text/event-stream' } })
.then(async (res) => {
if (!res.body) { clearTimeout(timer); finish(null); return; }
// Close the gap: a task may have arrived between the initial check and now.
const early = await findClaim();
if (early) { clearTimeout(timer); finish(early); return; }
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (!settled) {
let done: boolean; let value: Uint8Array | undefined;
try { ({ done, value } = await reader.read()); } catch { break; }
if (done) break;
if (value) buffer += decoder.decode(value, { stream: true });
const { events, remaining } = parseSSEBuffer(buffer);
buffer = remaining;
if (events.some((e) => e.type === 'task')) {
const claimed = await findClaim();
if (claimed) { clearTimeout(timer); finish(claimed); return; }
}
}
clearTimeout(timer); finish(null);
})
.catch(() => { clearTimeout(timer); finish(null); });
});
}
export async function startMcpServer(cwd: string): Promise<void> {
const { root, serverUrl } = resolveContext(cwd);
const remote = !!serverUrl;
const server = new McpServer({ name: 'agenthub', version: '0.7.0' });
server.tool('agenthub_hello', 'Announce yourself to the hub (presence). Run once when you start.',
{ agent: z.string(), role: z.string().optional() },
async ({ agent, role }) => {
if (remote) await remoteClient.announce(serverUrl!, agent, role ?? 'implementer');
return asText(`AgentHub: ${agent} joined (${role ?? 'implementer'})`);
});
server.tool('agenthub_work',
'Wait for a task addressed to you (newly delegated OR reopened), claim it, and return it with its handoff. Loop: work -> implement -> agenthub_task_review -> work.',
{ agent: z.string(), role: z.string().optional(), timeoutSec: z.number().optional() },
async ({ agent, role, timeoutSec }) => {
const ctx: AgentContext = { serverUrl, projectCwd: root, agent, role: role ?? 'implementer' };
const findClaim = async () => {
const found = await findAddressedOpenTask(ctx);
if (!found) return null;
if (remote) await remoteClient.claimTask(serverUrl!, found.task.id, agent);
else claimTask(root, found.task.id, agent);
const detail = remote ? await remoteClient.getTask(serverUrl!, found.task.id) : getTask(root, found.task.id);
const hofEntry = found.handoffs.find((h) => h.taskId === found.task.id);
let handoff: unknown = null;
if (hofEntry) {
try { handoff = remote ? await remoteClient.getHandoff(serverUrl!, hofEntry.id) : getHandoff(root, hofEntry.id); } catch { /* optional */ }
}
return { claimed: found.task, body: (detail as { body?: string }).body, handoff };
};
const immediate = await findClaim();
if (immediate) return asText(immediate);
if (!remote) return asText(`No open task addressed to ${agent}, and no hub server to wait on.`);
const claimed = await waitForTask(serverUrl!, findClaim, timeoutSec ?? 300);
return asText(claimed ?? `No task for ${agent} within ${timeoutSec ?? 300}s — call agenthub_work again.`);
});
server.tool('agenthub_task_list', 'List tasks, optionally filtered by status and/or role.',
{ status: z.string().optional(), role: z.string().optional() },
async ({ status, role }) => asText(remote ? await remoteClient.listTasks(serverUrl!, { status, role }) : listTasks(root, { status, role })));
server.tool('agenthub_task_show', 'Show one task with its full body.',
{ id: z.string() },
async ({ id }) => asText(remote ? await remoteClient.getTask(serverUrl!, id) : (() => { const t = getTask(root, id); return { task: t.task, body: t.body }; })()));
server.tool('agenthub_task_create', 'Create a task.',
{
title: z.string(),
role: z.enum(['architect', 'implementer', 'reviewer', 'tester']).optional(),
priority: z.enum(['low', 'medium', 'high', 'critical']).optional(),
},
async (o) => asText(remote ? await remoteClient.createTask(serverUrl!, o) : createTask(root, o)));
server.tool('agenthub_task_assign', 'Address an open task to an agent without claiming it (architect). The agent\'s agenthub_work then auto-claims it.',
{ id: z.string(), agent: z.string() },
async ({ id, agent }) => asText(remote ? await remoteClient.assignTask(serverUrl!, id, agent) : assignTask(root, id, agent)));
server.tool('agenthub_task_claim', 'Claim a task (set in_progress + assignedTo).',
{ id: z.string(), agent: z.string() },
async ({ id, agent }) => asText(remote ? await remoteClient.claimTask(serverUrl!, id, agent) : claimTask(root, id, agent)));
server.tool('agenthub_task_review', 'Submit a finished task for architect review. Implementers use THIS, never agenthub_task_done.',
{ id: z.string() },
async ({ id }) => asText(remote ? await remoteClient.reviewTask(serverUrl!, id) : reviewTask(root, id)));
server.tool('agenthub_task_reopen', 'Re-trigger a task after review (architect: send back to the implementer).',
{ id: z.string() },
async ({ id }) => asText(remote ? await remoteClient.reopenTask(serverUrl!, id) : reopenTask(root, id)));
server.tool('agenthub_task_done', 'Approve and close a task. ARCHITECT ONLY — implementers must use agenthub_task_review.',
{ id: z.string() },
async ({ id }) => asText(remote ? await remoteClient.doneTask(serverUrl!, id, {}) : doneTask(root, id)));
server.tool('agenthub_memory_add', 'Record a result / finding / blocker as a memory entry.',
{ title: z.string(), category: z.enum(['architecture', 'product', 'technical', 'implementation', 'lesson']).optional(), content: z.string() },
async (o) => asText(remote ? await remoteClient.addMemory(serverUrl!, o) : addMemory(root, o)));
server.tool('agenthub_memory_search', 'Search memory and tasks.',
{ q: z.string() },
async ({ q }) => asText(remote ? await remoteClient.searchMemory(serverUrl!, q) : searchMemory(root, q)));
server.tool('agenthub_handoff_read', 'Read a handoff (scope + context for a task).',
{ id: z.string() },
async ({ id }) => asText(remote ? await remoteClient.getHandoff(serverUrl!, id) : (() => { const h = getHandoff(root, id); return { handoff: h.handoff, body: h.body }; })()));
server.tool('agenthub_handoff_create', 'Create a handoff (architect delegates / gives feedback).',
{ fromRole: z.string(), toRole: z.string(), taskId: z.string().optional(), summary: z.string(), context: z.string().optional() },
async (o) => asText(remote ? await remoteClient.createHandoff(serverUrl!, o) : createHandoff(root, o)));
server.tool('agenthub_decision_create', 'Record an architecture/technical decision.',
{ title: z.string(), context: z.string().optional(), decision: z.string() },
async (o) => asText(remote ? await remoteClient.createDecision(serverUrl!, o) : createDecision(root, o)));
server.tool('agenthub_status', 'Show the current project status.',
{},
async () => asText(remote ? await remoteClient.getStatus(serverUrl!) : getStatus(root)));
const transport = new StdioServerTransport();
await server.connect(transport);
// stdio servers must not write to stdout (it's the protocol channel); log to stderr.
process.stderr.write(`AgentHub MCP server ready (${remote ? `hub ${serverUrl}` : `local ${root}`}).\n`);
}