42 lines
4.3 KiB
JavaScript
42 lines
4.3 KiB
JavaScript
/**
|
|
* hal/ai/mcp.mjs — HAL 도구를 MCP(Model Context Protocol, stdio JSON-RPC) 서버로 내보낸다(0.3.0).
|
|
*
|
|
* 용도: 서버에 로그인된 Claude Code 같은 MCP 클라이언트(로컬 CLI 연결의 `--mcp-config`)나 운영자의 개발 도구가 HAL 상태를 같은 도구 정의로 읽게 한다.
|
|
* node hal/index.mjs mcp (ARIWEB_HAL_DATA 가 가리키는 HAL 데이터로 연다. HAL 서비스와 같은 SQLite 를 WAL 로 함께 읽는다)
|
|
* 경계: 읽기 도구만 실행한다. 쓰기·고위험 도구는 [0.3.2] 승인 요청(ai_actions, 통합 방 카드)으로 올리고 actionId 를 돌려준다 — 운영자가 콘솔에서 승인(재인증·고위험 3단계)하면 실행되며
|
|
* 클라이언트는 hal_action_status 로 결과를 본다(MCP 경로에는 운영자 세션이 없기 때문). 임의 명령·셸 도구는 없다. stdout 에는 JSON-RPC 한 줄씩만 쓴다(로그는 stderr).
|
|
* 지원 메서드: initialize · notifications/initialized · ping · tools/list · tools/call. 그 밖은 -32601.
|
|
*/
|
|
import { createInterface } from 'node:readline';
|
|
import { toolByName, toolDefs, runTool, toolOutputText, RISK_LABEL } from './tools.mjs';
|
|
import { safeError } from '../../lib/redact.mjs';
|
|
|
|
export function runMcpServer(d, { input = process.stdin, output = process.stdout } = {}) {
|
|
const write = (o) => output.write(JSON.stringify(o) + '\n');
|
|
const reply = (id, result) => write({ jsonrpc: '2.0', id, result }); const fail = (id, code, message) => write({ jsonrpc: '2.0', id, error: { code, message } });
|
|
return new Promise((resolve) => {
|
|
const rl = createInterface({ input, crlfDelay: Infinity });
|
|
rl.on('line', async (line) => {
|
|
if (!line.trim()) return; let m; try { m = JSON.parse(line); } catch (_) { return fail(null, -32700, 'JSON 파싱 실패'); }
|
|
const { id, method, params = {} } = m || {};
|
|
try {
|
|
if (method === 'initialize') return reply(id, { protocolVersion: params.protocolVersion || '2025-06-18', capabilities: { tools: { listChanged: false } }, serverInfo: { name: 'Ariweb HAL MCP', version: d.version }, instructions: 'HAL 운영 도구. 읽기 도구만 실행되며 쓰기·고위험 도구는 운영자 콘솔 AI 채팅룸에서 승인을 거쳐야 한다.' });
|
|
if (method === 'notifications/initialized' || (method && method.startsWith('notifications/'))) return;
|
|
if (method === 'ping') return reply(id, {});
|
|
if (method === 'tools/list') return reply(id, { tools: toolDefs('mcp') });
|
|
if (method === 'tools/call') {
|
|
const tool = toolByName(params.name); if (!tool) return reply(id, { content: [{ type: 'text', text: `알 수 없는 도구: ${params.name}` }], isError: true });
|
|
if (tool.risk !== 'read') { // [0.3.2] 실행하지 않고 승인 요청(제안)을 올린다 — 운영자가 콘솔에서 승인(재인증, 고위험은 3단계 확인)하면 실행되고 hal_action_status 로 결과를 본다
|
|
try { const a = d.ai.proposeExternal({ source: 'mcp', tool: tool.name, args: params.arguments || {} }); return reply(id, { content: [{ type: 'text', text: JSON.stringify({ approvalRequested: true, actionId: a.id, status: a.status, risk: a.risk, riskLabel: a.riskLabel, message: `${tool.name} 은(는) ${RISK_LABEL[tool.risk]} 도구라 바로 실행하지 않았습니다. HAL 운영자 콘솔(통합 방)에 승인 요청을 올렸습니다 — 운영자가 승인(재인증${tool.risk === 'high' ? '·3단계 확인' : ''})하면 실행됩니다. hal_action_status(actionId) 로 결과를 확인하세요.` }) }] }); }
|
|
catch (e) { return reply(id, { content: [{ type: 'text', text: `승인 요청 실패(${e.code || 'error'}): ${safeError(e)}` }], isError: true }); }
|
|
}
|
|
try { const out = await runTool(d, tool, params.arguments || {}, { operatorId: null, label: 'mcp' }); return reply(id, { content: [{ type: 'text', text: toolOutputText(out) }] }); }
|
|
catch (e) { return reply(id, { content: [{ type: 'text', text: `도구 실행 실패(${e.code || 'error'}): ${safeError(e)}` }], isError: true }); }
|
|
}
|
|
if (id !== undefined) return fail(id, -32601, `지원하지 않는 메서드: ${method}`);
|
|
} catch (e) { if (id !== undefined) fail(id, -32603, safeError(e)); }
|
|
});
|
|
rl.on('close', () => resolve());
|
|
});
|
|
}
|