@ratifia/mcp 0.8.0 → 0.10.0
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 +20 -2
- package/dist/index.js +139 -8
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -58,9 +58,27 @@ real decisions. CI runs it on every push.
|
|
|
58
58
|
| `RATIFIA_API_URL` | | `https://api.ratifia.com` | Ratifia API base. |
|
|
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
|
-
| `RATIFIA_POLICY` | | — |
|
|
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. |
|
|
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. |
|
|
62
64
|
|
|
63
|
-
|
|
65
|
+
Who is notified is decided **inside Ratifia** by your org's policy — this server is a thin request/verdict bridge.
|
|
66
|
+
|
|
67
|
+
### Filing decisions: workflow and step
|
|
68
|
+
|
|
69
|
+
Every decision carries two labels:
|
|
70
|
+
|
|
71
|
+
- **Workflow** (`workflowRef`) — the *category*: "deploys", "refunds". The dashboard's Workflow filter groups by it. Set a default per server with `RATIFIA_WORKFLOW`, or pass `workflow` to `request_decision`.
|
|
72
|
+
- **Step** (`stepRef`) — the *specific instance* inside it: "api-v2.3-to-prod", "refund-INV-1042". Pass `step` to `request_decision`; it defaults to the title.
|
|
73
|
+
|
|
74
|
+
```
|
|
75
|
+
request_decision(
|
|
76
|
+
title: "Deploy API to prod?",
|
|
77
|
+
question: "Ship v2.3 to production?",
|
|
78
|
+
workflow: "deploys",
|
|
79
|
+
step: "api-v2.3-to-prod"
|
|
80
|
+
)
|
|
81
|
+
```
|
|
64
82
|
|
|
65
83
|
## Tools
|
|
66
84
|
|
package/dist/index.js
CHANGED
|
@@ -25,18 +25,36 @@
|
|
|
25
25
|
* RATIFIA_API_KEY org-scoped worker API key (required)
|
|
26
26
|
* RATIFIA_APP_URL dashboard base for the decision link (default https://app.ratifia.com)
|
|
27
27
|
* RATIFIA_APPROVER_EMAIL who the decision is assigned to / notified
|
|
28
|
-
* RATIFIA_POLICY optional: policy
|
|
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)
|
|
32
|
+
* RATIFIA_WORKFLOW optional: the workflow (category) decisions are filed under —
|
|
33
|
+
* what the dashboard's Workflow filter groups by. Defaults to
|
|
34
|
+
* `ratifia-mcp` (tools) / `ratifia-gate` (the gate hook).
|
|
30
35
|
*/
|
|
31
36
|
import { McpServer } from '@modelcontextprotocol/server';
|
|
32
37
|
import { StdioServerTransport } from '@modelcontextprotocol/server/stdio';
|
|
33
38
|
import { randomUUID } from 'node:crypto';
|
|
39
|
+
import { existsSync, readFileSync, statSync } from 'node:fs';
|
|
40
|
+
import { basename, dirname, join, resolve } from 'node:path';
|
|
34
41
|
import { z } from 'zod';
|
|
35
42
|
const API_URL = (process.env.RATIFIA_API_URL ?? 'https://api.ratifia.com').replace(/\/+$/, '');
|
|
36
43
|
const API_KEY = process.env.RATIFIA_API_KEY;
|
|
37
44
|
const APP_URL = (process.env.RATIFIA_APP_URL ?? 'https://app.ratifia.com').replace(/\/+$/, '');
|
|
38
45
|
const APPROVER_EMAIL = process.env.RATIFIA_APPROVER_EMAIL;
|
|
39
46
|
const POLICY = process.env.RATIFIA_POLICY;
|
|
47
|
+
const WORKFLOW = process.env.RATIFIA_WORKFLOW;
|
|
48
|
+
/**
|
|
49
|
+
* Where a decision is filed. `workflowRef` is the category (the dashboard's
|
|
50
|
+
* Workflow filter groups by it — e.g. "deploys", "billing-agent");
|
|
51
|
+
* `stepRef` is the specific instance within it. Blank or whitespace-only values
|
|
52
|
+
* fall back rather than filing under an empty name.
|
|
53
|
+
*/
|
|
54
|
+
function label(value, fallback, max) {
|
|
55
|
+
const v = value?.trim();
|
|
56
|
+
return (v ? v : fallback).slice(0, max);
|
|
57
|
+
}
|
|
40
58
|
if (!API_KEY) {
|
|
41
59
|
console.error('RATIFIA_API_KEY is required');
|
|
42
60
|
process.exit(1);
|
|
@@ -75,7 +93,7 @@ function errorCode(body) {
|
|
|
75
93
|
}
|
|
76
94
|
/** FN-255: RATIFIA_POLICY names a policy the org doesn't have. A misconfiguration, not an outage. */
|
|
77
95
|
const isUnknownPolicy = (err) => err instanceof ApiError && err.code === 'unknown_policy';
|
|
78
|
-
const unknownPolicyMessage = () => `RATIFIA_POLICY "${POLICY}"
|
|
96
|
+
const unknownPolicyMessage = () => `RATIFIA_POLICY "${POLICY}" is not a policy key in this Ratifia org. Create a policy with this key in the Ratifia dashboard (Policies) or fix RATIFIA_POLICY in this MCP server's config. No decision was created: do not proceed with the action.`;
|
|
79
97
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
80
98
|
/** `idempotencyKey` makes a retried write safe to collapse server-side; mint it
|
|
81
99
|
* once per tool invocation so two independent asks never share one. */
|
|
@@ -247,6 +265,87 @@ function gateDecision(decision, reason) {
|
|
|
247
265
|
},
|
|
248
266
|
}));
|
|
249
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
|
+
}
|
|
250
349
|
/**
|
|
251
350
|
* `ratifia-mcp gate` — a Claude Code **PreToolUse hook**. Opens a Ratifia
|
|
252
351
|
* decision for the tool call (rendered as a `tool_call` context panel), blocks
|
|
@@ -266,6 +365,23 @@ async function runGate() {
|
|
|
266
365
|
}
|
|
267
366
|
const toolName = String(payload.tool_name ?? 'unknown_tool');
|
|
268
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);
|
|
269
385
|
// Optional allow-list: if set, only gate these tools; pass the rest through.
|
|
270
386
|
const gated = (process.env.RATIFIA_GATED_TOOLS ?? '')
|
|
271
387
|
.split(',')
|
|
@@ -289,13 +405,17 @@ async function runGate() {
|
|
|
289
405
|
engine: 'mcp',
|
|
290
406
|
externalRunId,
|
|
291
407
|
stepRef: `tool:${toolName}`.slice(0, 80),
|
|
292
|
-
workflowRef: 'ratifia-gate',
|
|
408
|
+
workflowRef: label(WORKFLOW, 'ratifia-gate', 100),
|
|
293
409
|
...(APPROVER_EMAIL ? { assigneeEmail: APPROVER_EMAIL } : {}),
|
|
294
410
|
...(POLICY ? { policy: POLICY } : {}),
|
|
295
411
|
required: true,
|
|
296
412
|
context: {
|
|
297
|
-
prompt: `An agent wants to run the tool "${toolName}". Approve?`,
|
|
298
|
-
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
|
+
],
|
|
299
419
|
},
|
|
300
420
|
}),
|
|
301
421
|
}));
|
|
@@ -343,6 +463,7 @@ const server = new McpServer({ name: 'ratifia', version: '0.7.0' }, {
|
|
|
343
463
|
'',
|
|
344
464
|
'Workflow:',
|
|
345
465
|
'1. Call request_decision with a short title, a precise question, and enough context to decide (the command/diff/options + tradeoffs). Set timeout_minutes if it is time-sensitive.',
|
|
466
|
+
'Filing: set `workflow` to the project or ongoing effort the decision belongs to (e.g. the repo name, "billing-refunds") so a human can see related decisions together in the dashboard, and reuse the exact same string every time. Put what is specific to this decision (the release, record, environment) in `step`, e.g. "deploy-v2.3-prod". Without them every decision lands in one "ratifia-mcp" bucket.',
|
|
346
467
|
'2. It returns a decision_id (+ url). The human is notified and may be away from their keyboard, so a verdict can take a while. (If the org policy auto-resolved that no approval was needed, it returns approval_required=false — just proceed.)',
|
|
347
468
|
'3. Wait for the verdict: prefer await_decision (it blocks until answered) over polling check_decision in a loop. Either way, "approved"/"rejected" comes with a note — the note is the human\'s actual answer/input, so read and follow it. If you asked for a choice/typed value (response_type select/text), the answer is in response_value (selected option ids — map them back via response_spec.options — or text), not just the note.',
|
|
348
469
|
'4. Do NOT perform the gated action until it resolves approved. On rejected or expired, do not proceed — adapt or ask again.',
|
|
@@ -397,13 +518,23 @@ server.registerTool('request_decision', {
|
|
|
397
518
|
.record(z.string(), z.unknown())
|
|
398
519
|
.optional()
|
|
399
520
|
.describe('The arguments you would call `tool_name` with, as an object, e.g. { "amount_cents": 600000, "currency": "USD" }. Displayed next to the tool name. Ignored unless `tool_name` is set.'),
|
|
521
|
+
workflow: z
|
|
522
|
+
.string()
|
|
523
|
+
.max(100)
|
|
524
|
+
.optional()
|
|
525
|
+
.describe('The project or ongoing effort this decision belongs to, e.g. the repo name or "billing-refunds" — the dashboard groups and filters by it, so reuse the exact same string for related decisions and keep ids/versions out of it. Omit to use the server default (RATIFIA_WORKFLOW, else "ratifia-mcp").'),
|
|
526
|
+
step: z
|
|
527
|
+
.string()
|
|
528
|
+
.max(80)
|
|
529
|
+
.optional()
|
|
530
|
+
.describe('What is specific to this decision within the workflow, e.g. "deploy-v2.3-prod" or "refund-INV-1042". Omit to use the title.'),
|
|
400
531
|
evidence: z
|
|
401
532
|
.array(evidenceItemSchema)
|
|
402
533
|
.max(20)
|
|
403
534
|
.optional()
|
|
404
535
|
.describe('Material this decision should be made ON — the Slack thread, the email, the policy excerpt you are relying on. Stored immutably against the decision and sealed when it resolves, so the record shows what the human could see. Attach what you already have; use attach_evidence for anything you find afterwards.'),
|
|
405
536
|
},
|
|
406
|
-
}, async ({ title, question, context, timeout_minutes, response_type, options, allow_multiple, proposed_output, tool_name, tool_args, evidence }) => {
|
|
537
|
+
}, async ({ title, question, context, timeout_minutes, response_type, options, allow_multiple, proposed_output, tool_name, tool_args, workflow, step, evidence }) => {
|
|
407
538
|
if (response_type === 'select' && (!options || options.length < 2)) {
|
|
408
539
|
return asToolResult({
|
|
409
540
|
error: 'invalid_request',
|
|
@@ -435,8 +566,8 @@ server.registerTool('request_decision', {
|
|
|
435
566
|
// verdict rather than parking on an engine resume event.
|
|
436
567
|
engine: 'mcp',
|
|
437
568
|
externalRunId,
|
|
438
|
-
stepRef:
|
|
439
|
-
workflowRef: 'ratifia-mcp',
|
|
569
|
+
stepRef: label(step, title, 80),
|
|
570
|
+
workflowRef: label(workflow, label(WORKFLOW, 'ratifia-mcp', 100), 100),
|
|
440
571
|
...(APPROVER_EMAIL ? { assigneeEmail: APPROVER_EMAIL } : {}),
|
|
441
572
|
...(POLICY ? { policy: POLICY } : {}),
|
|
442
573
|
required: true,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ratifia/mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Ratifia MCP server \u2014 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": [
|