@ratifia/mcp 0.9.0 → 0.10.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -0
- package/dist/index.js +112 -3
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -59,6 +59,7 @@ real decisions. CI runs it on every push.
|
|
|
59
59
|
| `RATIFIA_APP_URL` | | `https://app.ratifia.com` | Dashboard base for decision links. |
|
|
60
60
|
| `RATIFIA_APPROVER_EMAIL` | | — | Who the decision is assigned to / notified. |
|
|
61
61
|
| `RATIFIA_POLICY` | | — | The **key** of a policy deciding **who** reviews and the quorum they need. It must exist in your org: an unknown key is a tool error, and the `gate` hook denies. |
|
|
62
|
+
| `RATIFIA_GATE_INCLUDE_PROMPT` | | off | `gate` hook only: `1` includes the user's last request (from the transcript) in the decision. Off by default — the gate otherwise sends only metadata, never your conversation. |
|
|
62
63
|
| `RATIFIA_WORKFLOW` | | `ratifia-mcp` (`ratifia-gate` for the hook) | The **workflow** decisions are filed under — the category the dashboard's Workflow filter groups by, e.g. `deploys` or `billing-agent`. A `request_decision` call can override it. |
|
|
63
64
|
|
|
64
65
|
Who is notified is decided **inside Ratifia** by your org's policy — this server is a thin request/verdict bridge.
|
package/dist/index.js
CHANGED
|
@@ -27,6 +27,8 @@
|
|
|
27
27
|
* RATIFIA_APPROVER_EMAIL who the decision is assigned to / notified
|
|
28
28
|
* RATIFIA_POLICY optional: policy key deciding who reviews (must exist in the org)
|
|
29
29
|
* (e.g. a route to your phone)
|
|
30
|
+
* RATIFIA_GATE_INCLUDE_PROMPT gate hook only: 1 = include the user's last request from the
|
|
31
|
+
* transcript in the decision (off by default — it is case content)
|
|
30
32
|
* RATIFIA_WORKFLOW optional: the workflow (category) decisions are filed under —
|
|
31
33
|
* what the dashboard's Workflow filter groups by. Defaults to
|
|
32
34
|
* `ratifia-mcp` (tools) / `ratifia-gate` (the gate hook).
|
|
@@ -34,6 +36,8 @@
|
|
|
34
36
|
import { McpServer } from '@modelcontextprotocol/server';
|
|
35
37
|
import { StdioServerTransport } from '@modelcontextprotocol/server/stdio';
|
|
36
38
|
import { randomUUID } from 'node:crypto';
|
|
39
|
+
import { existsSync, readFileSync, statSync } from 'node:fs';
|
|
40
|
+
import { basename, dirname, join, resolve } from 'node:path';
|
|
37
41
|
import { z } from 'zod';
|
|
38
42
|
const API_URL = (process.env.RATIFIA_API_URL ?? 'https://api.ratifia.com').replace(/\/+$/, '');
|
|
39
43
|
const API_KEY = process.env.RATIFIA_API_KEY;
|
|
@@ -261,6 +265,87 @@ function gateDecision(decision, reason) {
|
|
|
261
265
|
},
|
|
262
266
|
}));
|
|
263
267
|
}
|
|
268
|
+
/**
|
|
269
|
+
* Where the agent is working, read straight from `.git` rather than shelling out
|
|
270
|
+
* to git — a hook runs on every gated tool call and must stay cheap.
|
|
271
|
+
*/
|
|
272
|
+
function repoContext(cwd) {
|
|
273
|
+
let dir;
|
|
274
|
+
try {
|
|
275
|
+
dir = resolve(cwd);
|
|
276
|
+
}
|
|
277
|
+
catch {
|
|
278
|
+
return {};
|
|
279
|
+
}
|
|
280
|
+
for (let i = 0; i < 6; i++) {
|
|
281
|
+
try {
|
|
282
|
+
const dotGit = join(dir, '.git');
|
|
283
|
+
if (existsSync(dotGit)) {
|
|
284
|
+
// A worktree's `.git` is a FILE ("gitdir: <path>"); a normal repo's is a
|
|
285
|
+
// directory. Read HEAD from whichever this is.
|
|
286
|
+
const gitDir = statSync(dotGit).isDirectory()
|
|
287
|
+
? dotGit
|
|
288
|
+
: resolve(dir, readFileSync(dotGit, 'utf8').replace(/^gitdir:/, '').trim());
|
|
289
|
+
const head = readFileSync(join(gitDir, 'HEAD'), 'utf8').trim();
|
|
290
|
+
// Keep the whole branch name: "feat/x" is not "x".
|
|
291
|
+
const branch = head.startsWith('ref: refs/heads/')
|
|
292
|
+
? head.slice('ref: refs/heads/'.length)
|
|
293
|
+
: head.startsWith('ref: ')
|
|
294
|
+
? head.slice(head.lastIndexOf('/') + 1)
|
|
295
|
+
: head.slice(0, 7);
|
|
296
|
+
return { repo: basename(dir), branch };
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
catch {
|
|
300
|
+
// `.git` is a directory (readFileSync throws EISDIR) or unreadable — the
|
|
301
|
+
// repo name is still the useful half.
|
|
302
|
+
return { repo: basename(dir) };
|
|
303
|
+
}
|
|
304
|
+
const up = dirname(dir);
|
|
305
|
+
if (up === dir)
|
|
306
|
+
break;
|
|
307
|
+
dir = up;
|
|
308
|
+
}
|
|
309
|
+
return { repo: basename(resolve(cwd)) };
|
|
310
|
+
}
|
|
311
|
+
/**
|
|
312
|
+
* The user's most recent request, from the transcript the harness points at.
|
|
313
|
+
* OFF unless RATIFIA_GATE_INCLUDE_PROMPT=1: it is case content, and the gate is
|
|
314
|
+
* otherwise metadata only. The file is JSONL and written asynchronously, so the
|
|
315
|
+
* newest lines may lag; a miss just means no block.
|
|
316
|
+
*/
|
|
317
|
+
function lastUserRequest(transcriptPath) {
|
|
318
|
+
if (process.env.RATIFIA_GATE_INCLUDE_PROMPT !== '1')
|
|
319
|
+
return null;
|
|
320
|
+
if (typeof transcriptPath !== 'string' || !transcriptPath)
|
|
321
|
+
return null;
|
|
322
|
+
try {
|
|
323
|
+
const lines = readFileSync(transcriptPath, 'utf8').split('\n').filter(Boolean);
|
|
324
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
325
|
+
const row = JSON.parse(lines[i]);
|
|
326
|
+
if (row.type !== 'user' && row.message?.role !== 'user')
|
|
327
|
+
continue;
|
|
328
|
+
const content = row.message?.content;
|
|
329
|
+
const text = typeof content === 'string'
|
|
330
|
+
? content
|
|
331
|
+
: Array.isArray(content)
|
|
332
|
+
? content
|
|
333
|
+
.filter((c) => typeof c === 'object' && c !== null && c.type === 'text')
|
|
334
|
+
.map((c) => c.text)
|
|
335
|
+
.join('\n')
|
|
336
|
+
: '';
|
|
337
|
+
const trimmed = text.trim();
|
|
338
|
+
// Skip the harness's own injected turns — they are not what the user asked.
|
|
339
|
+
if (!trimmed || trimmed.startsWith('<'))
|
|
340
|
+
continue;
|
|
341
|
+
return trimmed.length > 1500 ? `${trimmed.slice(0, 1500)}…` : trimmed;
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
catch {
|
|
345
|
+
/* unreadable transcript — the gate still works without it */
|
|
346
|
+
}
|
|
347
|
+
return null;
|
|
348
|
+
}
|
|
264
349
|
/**
|
|
265
350
|
* `ratifia-mcp gate` — a Claude Code **PreToolUse hook**. Opens a Ratifia
|
|
266
351
|
* decision for the tool call (rendered as a `tool_call` context panel), blocks
|
|
@@ -280,6 +365,23 @@ async function runGate() {
|
|
|
280
365
|
}
|
|
281
366
|
const toolName = String(payload.tool_name ?? 'unknown_tool');
|
|
282
367
|
const toolInput = payload.tool_input ?? {};
|
|
368
|
+
// FN: the reviewer sees WHAT would run (the tool call) — give them WHERE and
|
|
369
|
+
// WHO too. All of it comes from the harness payload; none of it is guessed.
|
|
370
|
+
const cwd = typeof payload.cwd === 'string' ? payload.cwd : '';
|
|
371
|
+
const { repo, branch } = cwd ? repoContext(cwd) : {};
|
|
372
|
+
const where = [repo, branch && `(${branch})`].filter(Boolean).join(' ');
|
|
373
|
+
const askedFor = lastUserRequest(payload.transcript_path);
|
|
374
|
+
const fields = [
|
|
375
|
+
repo ? { label: 'Repo', value: branch ? `${repo} (${branch})` : repo } : null,
|
|
376
|
+
cwd ? { label: 'Directory', value: cwd } : null,
|
|
377
|
+
typeof payload.agent_type === 'string' && payload.agent_type ? { label: 'Agent', value: payload.agent_type } : null,
|
|
378
|
+
typeof payload.permission_mode === 'string' && payload.permission_mode
|
|
379
|
+
? { label: 'Permission mode', value: payload.permission_mode }
|
|
380
|
+
: null,
|
|
381
|
+
typeof payload.session_id === 'string' && payload.session_id
|
|
382
|
+
? { label: 'Session', value: payload.session_id.slice(0, 12) }
|
|
383
|
+
: null,
|
|
384
|
+
].filter((f) => f !== null);
|
|
283
385
|
// Optional allow-list: if set, only gate these tools; pass the rest through.
|
|
284
386
|
const gated = (process.env.RATIFIA_GATED_TOOLS ?? '')
|
|
285
387
|
.split(',')
|
|
@@ -308,8 +410,12 @@ async function runGate() {
|
|
|
308
410
|
...(POLICY ? { policy: POLICY } : {}),
|
|
309
411
|
required: true,
|
|
310
412
|
context: {
|
|
311
|
-
prompt: `An agent wants to run the tool "${toolName}". Approve?`,
|
|
312
|
-
blocks: [
|
|
413
|
+
prompt: `An agent wants to run the tool "${toolName}"${where ? ` in ${where}` : ''}. Approve?`,
|
|
414
|
+
blocks: [
|
|
415
|
+
{ kind: 'tool_call', title: 'Tool call', tool: toolName, args: toolInput },
|
|
416
|
+
...(fields.length ? [{ kind: 'fields', title: 'Where', fields }] : []),
|
|
417
|
+
...(askedFor ? [{ kind: 'text', title: 'What the user asked for', text: askedFor }] : []),
|
|
418
|
+
],
|
|
313
419
|
},
|
|
314
420
|
}),
|
|
315
421
|
}));
|
|
@@ -349,7 +455,10 @@ if (process.argv[2] === 'gate') {
|
|
|
349
455
|
await runGate();
|
|
350
456
|
process.exit(0);
|
|
351
457
|
}
|
|
352
|
-
|
|
458
|
+
/** The published version, read from package.json so the handshake can't drift
|
|
459
|
+
* from npm again (it reported 0.6.0/0.7.0 through several releases). */
|
|
460
|
+
const VERSION = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
|
|
461
|
+
const server = new McpServer({ name: 'ratifia', version: VERSION }, {
|
|
353
462
|
instructions: [
|
|
354
463
|
'Ratifia lets you request a HUMAN decision or input mid-task (human-in-the-loop) and resume once it is answered.',
|
|
355
464
|
'',
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ratifia/mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.1",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"description": "Ratifia MCP server
|
|
5
|
+
"description": "Ratifia MCP server — let an AI agent request a human decision/approval (HITL) mid-task and resume once it's answered. The agent becomes a Ratifia 'engine'.",
|
|
6
6
|
"keywords": [
|
|
7
7
|
"mcp",
|
|
8
8
|
"model-context-protocol",
|