#!/usr/bin/env node /** * jev-service MCP (stdio, zero deps). Typed decide/health against local or frp jev-server. * Credentials: ~/.secrets/jev/api.key and ~/.secrets/jev/base-url (or JEV_API_KEY / JEV_BASE_URL). * Never embeds secrets in this file or logs. */ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; const DEFAULT_BASE = 'http://127.0.0.1:8767'; const DEFAULT_TIMEOUT_MS = 15000; const SECRETS_DIR = path.join(os.homedir(), '.secrets', 'jev'); function readSecretFile(name) { const p = path.join(SECRETS_DIR, name); try { if (!fs.existsSync(p)) return null; return fs.readFileSync(p, 'utf8').trim() || null; } catch { return null; } } function resolveBaseUrl() { return (process.env.JEV_BASE_URL || readSecretFile('base-url') || DEFAULT_BASE).replace(/\/$/, ''); } function resolveApiKey() { return process.env.JEV_API_KEY || readSecretFile('api.key') || null; } function textResult(payload) { const body = typeof payload === 'string' ? payload : JSON.stringify(payload, null, 2); return { content: [{ type: 'text', text: body }] }; } function errResult(msg) { return { content: [{ type: 'text', text: msg }], isError: true }; } const TOOLS = [ { name: 'jev_health', description: 'Probe jev-service /healthz (no auth). Returns ok/model/llama_server status. Use before decide when unsure the service is up.', inputSchema: { type: 'object', properties: { base_url: { type: 'string', description: 'Optional override; default JEV_BASE_URL or ~/.secrets/jev/base-url or http://127.0.0.1:8767', }, timeout_ms: { type: 'number', description: 'Request timeout ms (default 15000)' }, }, additionalProperties: false, }, }, { name: 'jev_decide', description: 'Call jev-service POST /v1/decide: typed choice/noul/numeric answers with real probability distributions. For governance decisions (task tier, validation level, reject routing). Low confidence (top1-top2 < 0.15) → fall back to agent judgment. Requires API key from ~/.secrets/jev/api.key or JEV_API_KEY.', inputSchema: { type: 'object', properties: { context: { type: 'string', description: 'Situation text (user request, change summary, error code context, …)', }, questions: { type: 'object', description: 'Map of answer name → {type: choice|noul|numeric, question, options?, min?, max?}. Example: {"tier":{"type":"choice","question":"Task tier?","options":["info_reply","single_task","cross_module"]}}', additionalProperties: true, }, base_url: { type: 'string', description: 'Optional URL override' }, timeout_ms: { type: 'number', description: 'Request timeout ms (default 15000)' }, }, required: ['context', 'questions'], additionalProperties: false, }, }, ]; async function httpJson(url, { method = 'GET', headers = {}, body, timeoutMs = DEFAULT_TIMEOUT_MS } = {}) { const ctrl = new AbortController(); const timer = setTimeout(() => ctrl.abort(), timeoutMs); try { const res = await fetch(url, { method, headers, body, signal: ctrl.signal, }); const text = await res.text(); let json = null; try { json = text ? JSON.parse(text) : null; } catch { json = { raw: text }; } return { status: res.status, ok: res.ok, json, text }; } finally { clearTimeout(timer); } } async function callTool(name, args = {}) { const base = (args.base_url || resolveBaseUrl()).replace(/\/$/, ''); const timeoutMs = Number(args.timeout_ms) > 0 ? Number(args.timeout_ms) : DEFAULT_TIMEOUT_MS; switch (name) { case 'jev_health': { try { const r = await httpJson(`${base}/healthz`, { timeoutMs }); if (!r.ok) { return errResult(`jev_health HTTP ${r.status}: ${r.text.slice(0, 500)}`); } return textResult({ base_url: base, ...r.json }); } catch (e) { return errResult(`jev_health failed: ${e?.name === 'AbortError' ? 'timeout' : String(e?.message || e)}`); } } case 'jev_decide': { if (!args.context || typeof args.context !== 'string') { return errResult('jev_decide.context must be a non-empty string'); } if (!args.questions || typeof args.questions !== 'object' || Array.isArray(args.questions)) { return errResult('jev_decide.questions must be an object map'); } const key = resolveApiKey(); if (!key) { return errResult( 'jev API key missing: set JEV_API_KEY or create ~/.secrets/jev/api.key (chmod 600). Do not put the key in chat/repo.', ); } try { const r = await httpJson(`${base}/v1/decide`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${key}`, }, body: JSON.stringify({ context: args.context, questions: args.questions }), timeoutMs, }); if (r.status === 401) { return errResult('jev_decide unauthorized (401): check ~/.secrets/jev/api.key or JEV_API_KEY'); } if (!r.ok) { return errResult(`jev_decide HTTP ${r.status}: ${r.text.slice(0, 800)}`); } return textResult({ base_url: base, model: r.json?.model, answers: r.json?.answers, }); } catch (e) { return errResult(`jev_decide failed: ${e?.name === 'AbortError' ? 'timeout' : String(e?.message || e)}`); } } default: return errResult(`unknown tool: ${name}`); } } function send(msg) { // Cursor IDE/CLI stdio MCP expects newline-delimited JSON-RPC (same as pi-subagents). // Still accept Content-Length framed *requests* on stdin for compatibility. const body = JSON.stringify(msg); process.stdout.write(body + '\n'); } function handle(msg) { if (!msg || typeof msg !== 'object') return; if (msg.method && msg.id === undefined) { return; } const { id, method, params } = msg; const reply = (result) => send({ jsonrpc: '2.0', id, result }); const fail = (code, message) => send({ jsonrpc: '2.0', id, error: { code, message } }); try { if (method === 'initialize') { reply({ protocolVersion: params?.protocolVersion || '2024-11-05', capabilities: { tools: {} }, serverInfo: { name: 'jev', version: '1.0.0' }, }); return; } if (method === 'tools/list') { reply({ tools: TOOLS }); return; } if (method === 'tools/call') { const name = params?.name; const args = params?.arguments || {}; Promise.resolve(callTool(name, args)) .then((result) => reply(result)) .catch((e) => fail(-32000, String(e?.stack || e))); return; } if (method === 'ping') { reply({}); return; } fail(-32601, `Method not found: ${method}`); } catch (e) { fail(-32000, String(e?.stack || e)); } } // Support both Content-Length framed MCP and newline-delimited JSON (for tests). let buf = Buffer.alloc(0); process.stdin.on('data', (chunk) => { buf = Buffer.concat([buf, chunk]); while (true) { const headerEnd = buf.indexOf('\r\n\r\n'); if (headerEnd !== -1) { const header = buf.slice(0, headerEnd).toString('utf8'); const m = /Content-Length:\s*(\d+)/i.exec(header); if (!m) { buf = buf.slice(headerEnd + 4); continue; } const len = Number(m[1]); const start = headerEnd + 4; if (buf.length < start + len) return; const body = buf.slice(start, start + len).toString('utf8'); buf = buf.slice(start + len); try { handle(JSON.parse(body)); } catch (e) { process.stderr.write(`parse error: ${e}\n`); } continue; } const nl = buf.indexOf('\n'); if (nl === -1) return; const line = buf.slice(0, nl).toString('utf8').trim(); buf = buf.slice(nl + 1); if (!line || line.startsWith('Content-Length')) continue; try { handle(JSON.parse(line)); } catch (e) { process.stderr.write(`ndjson parse error: ${e}\n`); } } }); process.stdin.on('end', () => process.exit(0));