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:
parent
986639a5f3
commit
5586f2c15b
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "agenthub",
|
"name": "agenthub",
|
||||||
"version": "0.6.1",
|
"version": "0.7.0",
|
||||||
"description": "Local coordination layer for AI coding agents",
|
"description": "Local coordination layer for AI coding agents",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "./dist/index.js",
|
"main": "./dist/index.js",
|
||||||
@ -23,6 +23,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@inquirer/prompts": "^7.0.0",
|
"@inquirer/prompts": "^7.0.0",
|
||||||
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||||
"better-sqlite3": "^12.0.0",
|
"better-sqlite3": "^12.0.0",
|
||||||
"commander": "^13.0.0",
|
"commander": "^13.0.0",
|
||||||
"fastify": "^5.0.0",
|
"fastify": "^5.0.0",
|
||||||
|
|||||||
671
pnpm-lock.yaml
generated
671
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@ -11,6 +11,7 @@ import { update } from './commands/update.js';
|
|||||||
import { watchEvents } from './commands/watch.js';
|
import { watchEvents } from './commands/watch.js';
|
||||||
import { startAgent } from './commands/start.js';
|
import { startAgent } from './commands/start.js';
|
||||||
import { workAgent } from './commands/work.js';
|
import { workAgent } from './commands/work.js';
|
||||||
|
import { startMcpServer } from '../mcp/server.js';
|
||||||
import { loadConfig, saveConfig } from '../core/config.js';
|
import { loadConfig, saveConfig } from '../core/config.js';
|
||||||
import { findProjectRoot } from '../core/paths.js';
|
import { findProjectRoot } from '../core/paths.js';
|
||||||
import { discoverServer } from '../discovery.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 {
|
export function createProgram(cwd: string): Command {
|
||||||
const program = new Command('agenthub')
|
const program = new Command('agenthub')
|
||||||
.description('Local coordination layer for AI coding agents')
|
.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)');
|
.option('--server <url>', 'AgentHub server URL (env: AGENTHUB_SERVER)');
|
||||||
|
|
||||||
program
|
program
|
||||||
@ -481,6 +482,14 @@ export function createProgram(cwd: string): Command {
|
|||||||
await update();
|
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) ──────────────────────────────────────────────────────
|
// ─── hello (presence) ──────────────────────────────────────────────────────
|
||||||
program
|
program
|
||||||
.command('hello')
|
.command('hello')
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import { createProgram } from './cli/index.js';
|
|||||||
import { maybeNotifyUpdate } from './cli/commands/update.js';
|
import { maybeNotifyUpdate } from './cli/commands/update.js';
|
||||||
|
|
||||||
const argv = process.argv.slice(2);
|
const argv = process.argv.slice(2);
|
||||||
if (!argv.includes('update') && !argv.includes('server')) {
|
if (!argv.includes('update') && !argv.includes('server') && !argv.includes('mcp')) {
|
||||||
maybeNotifyUpdate();
|
maybeNotifyUpdate();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
194
src/mcp/server.ts
Normal file
194
src/mcp/server.ts
Normal 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`);
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user