feat(mcp): add jev-service governance decide MCP server
Expose typed jev_decide/jev_health over stdio for closed-enum governance routing; credentials stay in ~/.secrets/jev and out of git.
This commit is contained in:
+15
-4
@@ -1,12 +1,23 @@
|
||||
# agentctl MCP (stdio)
|
||||
# MCP servers (stdio)
|
||||
|
||||
Zero-dependency Node MCP server wrapping `scripts/agentctl.py`.
|
||||
Zero-dependency Node MCP wrappers used by Cursor (see [`.cursor/mcp.json`](../../.cursor/mcp.json)).
|
||||
|
||||
| Server | Script | Purpose |
|
||||
|---|---|---|
|
||||
| `agentctl` | `agentctl-mcp.mjs` | Lease / docs apply / validate / queue |
|
||||
| `jev` | `jev-mcp.mjs` | Typed decide/health against local or remote jev-service |
|
||||
|
||||
```bash
|
||||
# from repo root
|
||||
node scripts/mcp/agentctl-mcp.mjs
|
||||
node scripts/mcp/jev-mcp.mjs
|
||||
```
|
||||
|
||||
Cursor loads it via [`.cursor/mcp.json`](../../.cursor/mcp.json). Reload MCP after checkout.
|
||||
## jev credentials (not in git)
|
||||
|
||||
`lease_run` / `validate` take `command` as an argv array (no shell). Set `AGENTCTL_REPO` only when the script is not under the repo tree.
|
||||
- `~/.secrets/jev/api.key` (chmod 600) or env `JEV_API_KEY`
|
||||
- `~/.secrets/jev/base-url` or env `JEV_BASE_URL` (default `http://127.0.0.1:8767`)
|
||||
|
||||
Reload MCP after checkout. See skill `.cursor/skills/jev-decide/SKILL.md`.
|
||||
|
||||
`lease_run` / `validate` take `command` as an argv array (no shell). Set `AGENTCTL_REPO` only when the agentctl script is not under the repo tree.
|
||||
|
||||
Executable
+250
@@ -0,0 +1,250 @@
|
||||
#!/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) {
|
||||
const body = JSON.stringify(msg);
|
||||
process.stdout.write(`Content-Length: ${Buffer.byteLength(body, 'utf8')}\r\n\r\n${body}`);
|
||||
}
|
||||
|
||||
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));
|
||||
Reference in New Issue
Block a user