@ratifia/mcp 0.5.0 → 0.9.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.
Files changed (3) hide show
  1. package/README.md +81 -2
  2. package/dist/index.js +391 -144
  3. package/package.json +7 -6
package/README.md CHANGED
@@ -44,6 +44,12 @@ Point separate registrations at different environments by giving each its own `R
44
44
  Clone, then `npm install && npm run build` (rebuild after source changes). Use
45
45
  `npx tsx src/index.ts` instead of the built file to skip the build step while iterating.
46
46
 
47
+ `npm run validate` checks the built server against the MCP protocol using the
48
+ [Inspector CLI](https://modelcontextprotocol.io/docs/tools/inspector/cli) — the
49
+ handshake, the advertised tool surface and schemas, and a full create → await →
50
+ resolve cycle — driven against a stub API, so it needs no key and creates no
51
+ real decisions. CI runs it on every push.
52
+
47
53
  ## Configuration (env)
48
54
 
49
55
  | Var | Required | Default | Purpose |
@@ -52,9 +58,26 @@ Clone, then `npm install && npm run build` (rebuild after source changes). Use
52
58
  | `RATIFIA_API_URL` | | `https://api.ratifia.com` | Ratifia API base. |
53
59
  | `RATIFIA_APP_URL` | | `https://app.ratifia.com` | Dashboard base for decision links. |
54
60
  | `RATIFIA_APPROVER_EMAIL` | | — | Who the decision is assigned to / notified. |
55
- | `RATIFIA_POLICY` | | — | Named policy whose route decides **how** you're notified (e.g. to your phone). |
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_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
+ Who is notified is decided **inside Ratifia** by your org's policy — this server is a thin request/verdict bridge.
65
+
66
+ ### Filing decisions: workflow and step
67
+
68
+ Every decision carries two labels:
56
69
 
57
- Routing (email / SMS / escalation) is decided **inside Ratifia** by your org's policy — this server is a thin request/verdict bridge.
70
+ - **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`.
71
+ - **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.
72
+
73
+ ```
74
+ request_decision(
75
+ title: "Deploy API to prod?",
76
+ question: "Ship v2.3 to production?",
77
+ workflow: "deploys",
78
+ step: "api-v2.3-to-prod"
79
+ )
80
+ ```
58
81
 
59
82
  ## Tools
60
83
 
@@ -65,6 +88,29 @@ Routing (email / SMS / escalation) is decided **inside Ratifia** by your org's p
65
88
  | `await_decision` | Block until the verdict lands (or a max wait elapses). |
66
89
  | `list_pending_decisions` | The agent's inbox — what's outstanding. |
67
90
  | `cancel_decision` | Withdraw a decision the agent no longer needs. |
91
+ | `attach_evidence` | Attach immutable material the decision is judged **on** (a Slack thread, an email). |
92
+
93
+ ### Waiting for a verdict
94
+
95
+ `await_decision` blocks for `max_wait_seconds` (default **90**, max **110**) and
96
+ returns the moment the human answers. If they haven't yet, it comes back
97
+ `pending` with `timed_out: true` — **that's normal, just call it again.**
98
+
99
+ The 110s ceiling is deliberate: agent harnesses background a tool call that runs
100
+ much past ~120s, so a longer block doesn't reach the agent sooner, it just lands
101
+ late. While waiting, the server polls Ratifia every 1s for the first 15s (most
102
+ decisions land there), then eases to 3s and 5s.
103
+
104
+ Transient failures — network blips, 429s, 5xx — are retried internally. One that
105
+ still surfaces comes back as data rather than an exception:
106
+
107
+ ```json
108
+ { "error": "unreachable", "message": "...", "retryable": true }
109
+ ```
110
+
111
+ On `retryable: true` the call is worth repeating; the decision is still open and
112
+ the human has already been notified, so **retry rather than requesting a second
113
+ decision.**
68
114
 
69
115
  ## Decision shapes
70
116
 
@@ -121,6 +167,39 @@ request_decision(
121
167
  `tool_args` is optional (some tools take none); it's ignored unless `tool_name`
122
168
  is set.
123
169
 
170
+ ### Evidence — what the decision was made *on*
171
+
172
+ A decision often rests on something outside the ask: the Slack thread where a
173
+ customer complained, the email that set the terms, the policy clause that
174
+ permits it. Attach that material and it becomes part of the decision's record.
175
+
176
+ ```
177
+ request_decision(
178
+ title: "Approve refund?",
179
+ question: "This exceeds the $5,000 auto-approve floor.",
180
+ evidence: [{
181
+ kind: "slack_thread",
182
+ title: "#support — refund thread",
183
+ source_url: "https://acme.slack.com/archives/C123/p1700000000",
184
+ body: "ana: customer is furious\nbo: refund is within policy"
185
+ }]
186
+ )
187
+ ```
188
+
189
+ Found something after you asked? `attach_evidence(decision_id, ...)` appends it.
190
+
191
+ Three properties worth knowing, because they shape how you should use it:
192
+
193
+ - **Send the text, not just a link.** The stored snapshot (and its sha256) is
194
+ what the audit trail keeps. A permalink can be edited or deleted after the
195
+ fact; the snapshot can't.
196
+ - **Sealed at the verdict.** Evidence can only be attached while the decision is
197
+ pending — so attach *before* you `await_decision`. Afterwards you get
198
+ `http_409` with `retryable: false`. That's the point: the record shows exactly
199
+ what the reviewer could see.
200
+ - **Re-attaching is free.** Identical content dedupes on its hash, so a retry
201
+ never produces a second panel for the reviewer.
202
+
124
203
  ## How it works
125
204
 
126
205
  1. The agent calls `request_decision` before a consequential/irreversible step (deploy, delete, spend, send externally), an ambiguous call it shouldn't guess, or to get a human to pick/refine something.
package/dist/index.js CHANGED
@@ -12,6 +12,8 @@
12
12
  * so the agent doesn't have to poll in a loop.
13
13
  * - list_pending_decisions : the agent's own inbox — what's outstanding.
14
14
  * - cancel_decision : withdraw a decision the agent no longer needs.
15
+ * - attach_evidence : append immutable material the decision is judged ON
16
+ * (a Slack thread, an email). Sealed at the verdict.
15
17
  *
16
18
  * Routing (HOW a human is reached — email/SMS/phone/escalation) is decided
17
19
  * INSIDE Ratifia by the org's policy/route, never by this client. The MCP is a
@@ -23,36 +25,148 @@
23
25
  * RATIFIA_API_KEY org-scoped worker API key (required)
24
26
  * RATIFIA_APP_URL dashboard base for the decision link (default https://app.ratifia.com)
25
27
  * RATIFIA_APPROVER_EMAIL who the decision is assigned to / notified
26
- * RATIFIA_POLICY optional: policy name whose route decides HOW you're notified
28
+ * RATIFIA_POLICY optional: policy key deciding who reviews (must exist in the org)
27
29
  * (e.g. a route to your phone)
30
+ * RATIFIA_WORKFLOW optional: the workflow (category) decisions are filed under —
31
+ * what the dashboard's Workflow filter groups by. Defaults to
32
+ * `ratifia-mcp` (tools) / `ratifia-gate` (the gate hook).
28
33
  */
29
- import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
30
- import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
34
+ import { McpServer } from '@modelcontextprotocol/server';
35
+ import { StdioServerTransport } from '@modelcontextprotocol/server/stdio';
36
+ import { randomUUID } from 'node:crypto';
31
37
  import { z } from 'zod';
32
38
  const API_URL = (process.env.RATIFIA_API_URL ?? 'https://api.ratifia.com').replace(/\/+$/, '');
33
39
  const API_KEY = process.env.RATIFIA_API_KEY;
34
40
  const APP_URL = (process.env.RATIFIA_APP_URL ?? 'https://app.ratifia.com').replace(/\/+$/, '');
35
41
  const APPROVER_EMAIL = process.env.RATIFIA_APPROVER_EMAIL;
36
42
  const POLICY = process.env.RATIFIA_POLICY;
43
+ const WORKFLOW = process.env.RATIFIA_WORKFLOW;
44
+ /**
45
+ * Where a decision is filed. `workflowRef` is the category (the dashboard's
46
+ * Workflow filter groups by it — e.g. "deploys", "billing-agent");
47
+ * `stepRef` is the specific instance within it. Blank or whitespace-only values
48
+ * fall back rather than filing under an empty name.
49
+ */
50
+ function label(value, fallback, max) {
51
+ const v = value?.trim();
52
+ return (v ? v : fallback).slice(0, max);
53
+ }
37
54
  if (!API_KEY) {
38
55
  console.error('RATIFIA_API_KEY is required');
39
56
  process.exit(1);
40
57
  }
58
+ /** Per-request ceiling. Node's fetch defaults run to ~300s, long enough to
59
+ * outlive a whole await_decision budget and strand the caller on a dead
60
+ * connection well past the deadline it was promised. */
61
+ const REQUEST_TIMEOUT_MS = 15_000;
62
+ /** An API failure that knows whether trying again is worth anything. */
63
+ class ApiError extends Error {
64
+ /** null = no response at all (network, DNS, or our own timeout). */
65
+ status;
66
+ /** The API's machine-readable `error` code, when it sent one (e.g. `unknown_policy`). */
67
+ code;
68
+ constructor(message, status, code = null) {
69
+ super(message);
70
+ this.status = status;
71
+ this.code = code;
72
+ }
73
+ get retryable() {
74
+ if (this.status === null)
75
+ return true;
76
+ if (this.status === 408 || this.status === 429)
77
+ return true;
78
+ return this.status >= 500;
79
+ }
80
+ }
81
+ function errorCode(body) {
82
+ try {
83
+ const code = JSON.parse(body).error;
84
+ return typeof code === 'string' ? code : null;
85
+ }
86
+ catch {
87
+ return null;
88
+ }
89
+ }
90
+ /** FN-255: RATIFIA_POLICY names a policy the org doesn't have. A misconfiguration, not an outage. */
91
+ const isUnknownPolicy = (err) => err instanceof ApiError && err.code === 'unknown_policy';
92
+ 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.`;
93
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
94
+ /** `idempotencyKey` makes a retried write safe to collapse server-side; mint it
95
+ * once per tool invocation so two independent asks never share one. */
41
96
  async function api(path, init) {
42
- const res = await fetch(`${API_URL}${path}`, {
43
- ...init,
44
- headers: {
45
- 'Content-Type': 'application/json',
46
- Authorization: `Bearer ${API_KEY}`,
47
- ...(init?.headers ?? {}),
48
- },
49
- });
50
- const text = await res.text();
51
- if (!res.ok)
52
- throw new Error(`Ratifia ${path} → ${res.status}: ${text.slice(0, 300)}`);
53
- return (text ? JSON.parse(text) : {});
97
+ const { idempotencyKey, ...rest } = init ?? {};
98
+ try {
99
+ const res = await fetch(`${API_URL}${path}`, {
100
+ ...rest,
101
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
102
+ headers: {
103
+ 'Content-Type': 'application/json',
104
+ Authorization: `Bearer ${API_KEY}`,
105
+ ...(idempotencyKey ? { 'Idempotency-Key': idempotencyKey } : {}),
106
+ ...(rest.headers ?? {}),
107
+ },
108
+ });
109
+ const text = await res.text();
110
+ if (!res.ok)
111
+ throw new ApiError(`Ratifia ${path} → ${res.status}: ${text.slice(0, 300)}`, res.status, errorCode(text));
112
+ return (text ? JSON.parse(text) : {});
113
+ }
114
+ catch (err) {
115
+ if (err instanceof ApiError)
116
+ throw err;
117
+ const msg = err instanceof Error ? err.message : String(err);
118
+ throw new ApiError(`Ratifia ${path} → unreachable: ${msg}`, null);
119
+ }
120
+ }
121
+ /** Retry transient failures with backoff. One blip used to abort an entire
122
+ * wait, which reads to the agent as "this step failed" — the expensive
123
+ * outcome, since it may then re-ask a human who was already notified. */
124
+ async function apiRetry(fn, attempts = 3) {
125
+ for (let i = 0;; i++) {
126
+ try {
127
+ return await fn();
128
+ }
129
+ catch (err) {
130
+ if (!(err instanceof ApiError) || !err.retryable || i >= attempts - 1)
131
+ throw err;
132
+ await sleep(500 * 2 ** i); // 500ms, then 1s
133
+ }
134
+ }
54
135
  }
55
136
  const getDecision = (id) => api(`/v1/worker/decisions/${encodeURIComponent(id)}`, { method: 'GET' });
137
+ /** Evidence lives on the dual-auth decisions route, not /worker — an API key
138
+ * authenticates there exactly as a dashboard session does. */
139
+ const attachEvidence = (decisionId, e) => api(`/v1/decisions/${encodeURIComponent(decisionId)}/evidence`, {
140
+ method: 'POST',
141
+ body: JSON.stringify({
142
+ kind: e.kind,
143
+ ...(e.title ? { title: e.title } : {}),
144
+ ...(e.source_system ? { sourceSystem: e.source_system } : {}),
145
+ ...(e.source_url ? { sourceUrl: e.source_url } : {}),
146
+ ...(e.body ? { body: e.body } : {}),
147
+ ...(e.captured_at ? { capturedAt: e.captured_at } : {}),
148
+ }),
149
+ });
150
+ /** Poll fast while the human is plausibly still at the keyboard, then back off.
151
+ * Most decisions land in the first few seconds, and a flat interval made every
152
+ * one of those wait out the full tick before the agent could move. */
153
+ const pollIntervalMs = (elapsedMs) => elapsedMs < 15_000 ? 1_000 : elapsedMs < 60_000 ? 3_000 : 5_000;
154
+ /** Poll until the decision leaves PENDING or the budget runs out (returns it
155
+ * still PENDING in that case). Transient API failures are retried, not fatal. */
156
+ async function waitForDecision(decisionId, budgetMs) {
157
+ const start = Date.now();
158
+ let d = await apiRetry(() => getDecision(decisionId));
159
+ for (;;) {
160
+ if (d.status !== 'PENDING')
161
+ return d;
162
+ const elapsed = Date.now() - start;
163
+ const wait = pollIntervalMs(elapsed);
164
+ if (elapsed + wait > budgetMs)
165
+ return d;
166
+ await sleep(wait);
167
+ d = await apiRetry(() => getDecision(decisionId));
168
+ }
169
+ }
56
170
  /** Agent-facing shape for a decision's current state. */
57
171
  const formatDecision = (d) => ({
58
172
  decision_id: d.id,
@@ -74,6 +188,33 @@ const formatDecision = (d) => ({
74
188
  const asToolResult = (payload) => ({
75
189
  content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }],
76
190
  });
191
+ /** Report an API failure as a structured result rather than an MCP exception,
192
+ * so the agent can retry on `retryable` instead of treating the step as lost
193
+ * and abandoning a decision a human may already be looking at. */
194
+ const asToolError = (err) => isUnknownPolicy(err)
195
+ ? asToolResult({ error: 'unknown_policy', policy: POLICY, message: unknownPolicyMessage(), retryable: false })
196
+ : asToolResult({
197
+ error: err instanceof ApiError && err.status ? `http_${err.status}` : 'unreachable',
198
+ message: err instanceof Error ? err.message : String(err),
199
+ retryable: err instanceof ApiError ? err.retryable : true,
200
+ });
201
+ /** FN-239: one piece of evidence, shared by request_decision and attach_evidence. */
202
+ const evidenceItemSchema = z.object({
203
+ kind: z
204
+ .enum(['slack_thread', 'email', 'document', 'link', 'note'])
205
+ .describe("What this material is. 'slack_thread' / 'email' for a captured conversation, 'document' for a doc or policy excerpt, 'link' for a bare reference with no text, 'note' for your own written rationale."),
206
+ title: z.string().optional().describe('Short reviewer-facing label, e.g. "#incidents — refund thread".'),
207
+ source_system: z.string().optional().describe('Where it came from, e.g. "slack", "email", "notion".'),
208
+ source_url: z.string().optional().describe('Deep link back to the original, for provenance.'),
209
+ body: z
210
+ .string()
211
+ .optional()
212
+ .describe('The actual text, captured. Send the content, not just a link — a link can be edited or deleted after the decision, and the stored snapshot is what the audit trail keeps. Required unless kind is "link".'),
213
+ captured_at: z
214
+ .string()
215
+ .optional()
216
+ .describe('ISO timestamp of when the material was originally written, if it predates now.'),
217
+ });
77
218
  function buildResponseSpec(responseType, options, allowMultiple) {
78
219
  if (!responseType || responseType === 'approval')
79
220
  return undefined;
@@ -152,14 +293,17 @@ async function runGate() {
152
293
  const budgetMs = Math.max(10, Number(process.env.RATIFIA_GATE_TIMEOUT_SEC ?? 240)) * 1000;
153
294
  let decisionId;
154
295
  try {
155
- const externalRunId = `gate-${Date.now()}-${Math.round(Math.random() * 1e6)}`;
156
- const res = await api('/v1/worker/decisions', {
296
+ // One id per hook invocation: retries below collapse into this decision,
297
+ // while a genuinely separate tool call mints its own and gates separately.
298
+ const externalRunId = `gate-${randomUUID()}`;
299
+ const res = await apiRetry(() => api('/v1/worker/decisions', {
157
300
  method: 'POST',
301
+ idempotencyKey: externalRunId,
158
302
  body: JSON.stringify({
159
303
  engine: 'mcp',
160
304
  externalRunId,
161
305
  stepRef: `tool:${toolName}`.slice(0, 80),
162
- workflowRef: 'ratifia-gate',
306
+ workflowRef: label(WORKFLOW, 'ratifia-gate', 100),
163
307
  ...(APPROVER_EMAIL ? { assigneeEmail: APPROVER_EMAIL } : {}),
164
308
  ...(POLICY ? { policy: POLICY } : {}),
165
309
  required: true,
@@ -168,7 +312,7 @@ async function runGate() {
168
312
  blocks: [{ kind: 'tool_call', title: 'Tool call', tool: toolName, args: toolInput }],
169
313
  },
170
314
  }),
171
- });
315
+ }));
172
316
  // Policy gate resolved "no approval needed" → let it run.
173
317
  if (res.approvalNeeded === false || !res.decisionId) {
174
318
  return gateDecision('allow', 'no approval required by policy');
@@ -176,26 +320,24 @@ async function runGate() {
176
320
  decisionId = res.decisionId;
177
321
  }
178
322
  catch (err) {
323
+ // A bad RATIFIA_POLICY denies even with fail-open: fail-open is for Ratifia
324
+ // being unreachable, and letting a misconfigured gate wave every tool call
325
+ // through is exactly the outage it exists to prevent.
326
+ if (isUnknownPolicy(err))
327
+ return gateDecision('deny', unknownPolicyMessage());
179
328
  const msg = err instanceof Error ? err.message : String(err);
180
329
  return gateDecision(failOpen ? 'allow' : 'deny', `ratifia gate unreachable: ${msg}`);
181
330
  }
182
331
  // Block until the human answers (or the budget elapses → fail-closed).
183
- const start = Date.now();
184
- const pollMs = 4000;
185
332
  try {
186
- for (;;) {
187
- const d = await getDecision(decisionId);
188
- if (d.status === 'APPROVED')
189
- return gateDecision('allow', d.note ?? 'approved by reviewer');
190
- if (d.status === 'REJECTED')
191
- return gateDecision('deny', d.note ?? 'rejected by reviewer');
192
- if (d.status !== 'PENDING')
193
- return gateDecision('deny', `decision ${d.status.toLowerCase()}`);
194
- if (Date.now() - start + pollMs > budgetMs) {
195
- return gateDecision(failOpen ? 'allow' : 'deny', 'timed out waiting for approval');
196
- }
197
- await new Promise((r) => setTimeout(r, pollMs));
198
- }
333
+ const d = await waitForDecision(decisionId, budgetMs);
334
+ if (d.status === 'APPROVED')
335
+ return gateDecision('allow', d.note ?? 'approved by reviewer');
336
+ if (d.status === 'REJECTED')
337
+ return gateDecision('deny', d.note ?? 'rejected by reviewer');
338
+ if (d.status !== 'PENDING')
339
+ return gateDecision('deny', `decision ${d.status.toLowerCase()}`);
340
+ return gateDecision(failOpen ? 'allow' : 'deny', 'timed out waiting for approval');
199
341
  }
200
342
  catch (err) {
201
343
  const msg = err instanceof Error ? err.message : String(err);
@@ -207,7 +349,7 @@ if (process.argv[2] === 'gate') {
207
349
  await runGate();
208
350
  process.exit(0);
209
351
  }
210
- const server = new McpServer({ name: 'ratifia', version: '0.5.0' }, {
352
+ const server = new McpServer({ name: 'ratifia', version: '0.7.0' }, {
211
353
  instructions: [
212
354
  'Ratifia lets you request a HUMAN decision or input mid-task (human-in-the-loop) and resume once it is answered.',
213
355
  '',
@@ -215,58 +357,78 @@ const server = new McpServer({ name: 'ratifia', version: '0.5.0' }, {
215
357
  '',
216
358
  'Workflow:',
217
359
  '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.',
360
+ '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.',
218
361
  '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.)',
219
362
  '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.',
220
363
  '4. Do NOT perform the gated action until it resolves approved. On rejected or expired, do not proceed — adapt or ask again.',
221
364
  'Refining content: to have a human review/edit a draft (e.g. an email you want to send), pass proposed_output.content. They can refine it (in the dashboard or by replying in the Slack thread); once approved, use output.content as the final text — it may differ from what you sent.',
365
+ 'Evidence: if your ask rests on material the human should see — a Slack conversation, an email, a policy excerpt — attach it (the `evidence` array on request_decision, or attach_evidence afterwards). Send the text, not just a link: the snapshot is what the audit trail keeps, and a link can change. Evidence can only be attached while the decision is pending; once it resolves the set is sealed, so attach before you await.',
222
366
  'Prefer one well-scoped decision with rich context over many small ones.',
223
367
  ].join('\n'),
224
368
  });
225
- server.tool('request_decision', 'Ask the human for a decision or input before proceeding (human-in-the-loop). Creates a Ratifia decision, notifies them (even away from keyboard), and returns a decision_id to await/poll. Use whenever you need approval, a choice, or input you should not assume. Set response_type to shape the answer: "approval" (yes/no, default), "select" (pick from options you provide), or "text" (a typed value) — for select/text the answer comes back in response_value. To have a human refine a draft (e.g. an email), pass proposed_output — the approved/edited text comes back in output.content. If this decision gates a specific tool/function call, pass tool_name (and tool_args) so the reviewer sees a structured "Tool call" panel of exactly what you would run.', {
226
- title: z.string().describe('Short title, e.g. "Deploy API to prod?"'),
227
- question: z.string().describe('Exactly what you need the human to decide or provide.'),
228
- context: z
229
- .string()
230
- .optional()
231
- .describe('Details to help them decide: options, tradeoffs, a diff, the command you would run.'),
232
- timeout_minutes: z
233
- .number()
234
- .int()
235
- .positive()
236
- .optional()
237
- .describe('Auto-expire (and reject) if unanswered after N minutes. Omit to wait indefinitely.'),
238
- response_type: z
239
- .enum(['approval', 'select', 'text'])
240
- .optional()
241
- .describe("What kind of answer you need. 'approval' (default) = yes/no gate. 'select' = the human picks from `options`. 'text' = the human types a value. For select/text, read the answer from `response_value` (not just `note`)."),
242
- options: z
243
- .array(z.object({
244
- label: z.string().describe('What the human sees for this choice.'),
245
- description: z.string().optional().describe('Optional one-line clarification of the choice.'),
246
- }))
247
- .min(2)
248
- .optional()
249
- .describe("The choices for a 'select' decision (at least 2). Required when response_type is 'select'."),
250
- allow_multiple: z
251
- .boolean()
252
- .optional()
253
- .describe("For 'select': allow picking more than one option (default single-choice)."),
254
- proposed_output: z
255
- .object({
256
- title: z.string().optional().describe('Short label for the artifact, e.g. "Email body".'),
257
- content: z.string().describe('The draft the human can edit before approving (e.g. the email text).'),
258
- })
259
- .optional()
260
- .describe('A draft artifact the human can refine before approving — e.g. an email you propose to send. They can edit it (in the dashboard or by replying in the Slack thread); on approval the final text comes back in `output.content`. Use this when you want a human to review/refine content, not just say yes/no.'),
261
- tool_name: z
262
- .string()
263
- .optional()
264
- .describe('The tool/function this decision gates, e.g. "issue_refund" or "delete_database". Shown to the reviewer as a structured "Tool call" panel — the concrete action you would take if approved.'),
265
- tool_args: z
266
- .record(z.unknown())
267
- .optional()
268
- .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.'),
269
- }, async ({ title, question, context, timeout_minutes, response_type, options, allow_multiple, proposed_output, tool_name, tool_args }) => {
369
+ server.registerTool('request_decision', {
370
+ description: 'Ask the human for a decision or input before proceeding (human-in-the-loop). Creates a Ratifia decision, notifies them (even away from keyboard), and returns a decision_id to await/poll. Use whenever you need approval, a choice, or input you should not assume. Set response_type to shape the answer: "approval" (yes/no, default), "select" (pick from options you provide), or "text" (a typed value) — for select/text the answer comes back in response_value. To have a human refine a draft (e.g. an email), pass proposed_output — the approved/edited text comes back in output.content. If this decision gates a specific tool/function call, pass tool_name (and tool_args) so the reviewer sees a structured "Tool call" panel of exactly what you would run.',
371
+ inputSchema: {
372
+ title: z.string().describe('Short title, e.g. "Deploy API to prod?"'),
373
+ question: z.string().describe('Exactly what you need the human to decide or provide.'),
374
+ context: z
375
+ .string()
376
+ .optional()
377
+ .describe('Details to help them decide: options, tradeoffs, a diff, the command you would run.'),
378
+ timeout_minutes: z
379
+ .number()
380
+ .int()
381
+ .positive()
382
+ .optional()
383
+ .describe('Auto-expire (and reject) if unanswered after N minutes. Omit to wait indefinitely.'),
384
+ response_type: z
385
+ .enum(['approval', 'select', 'text'])
386
+ .optional()
387
+ .describe("What kind of answer you need. 'approval' (default) = yes/no gate. 'select' = the human picks from `options`. 'text' = the human types a value. For select/text, read the answer from `response_value` (not just `note`)."),
388
+ options: z
389
+ .array(z.object({
390
+ label: z.string().describe('What the human sees for this choice.'),
391
+ description: z.string().optional().describe('Optional one-line clarification of the choice.'),
392
+ }))
393
+ .min(2)
394
+ .optional()
395
+ .describe("The choices for a 'select' decision (at least 2). Required when response_type is 'select'."),
396
+ allow_multiple: z
397
+ .boolean()
398
+ .optional()
399
+ .describe("For 'select': allow picking more than one option (default single-choice)."),
400
+ proposed_output: z
401
+ .object({
402
+ title: z.string().optional().describe('Short label for the artifact, e.g. "Email body".'),
403
+ content: z.string().describe('The draft the human can edit before approving (e.g. the email text).'),
404
+ })
405
+ .optional()
406
+ .describe('A draft artifact the human can refine before approving — e.g. an email you propose to send. They can edit it (in the dashboard or by replying in the Slack thread); on approval the final text comes back in `output.content`. Use this when you want a human to review/refine content, not just say yes/no.'),
407
+ tool_name: z
408
+ .string()
409
+ .optional()
410
+ .describe('The tool/function this decision gates, e.g. "issue_refund" or "delete_database". Shown to the reviewer as a structured "Tool call" panel — the concrete action you would take if approved.'),
411
+ tool_args: z
412
+ .record(z.string(), z.unknown())
413
+ .optional()
414
+ .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.'),
415
+ workflow: z
416
+ .string()
417
+ .max(100)
418
+ .optional()
419
+ .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").'),
420
+ step: z
421
+ .string()
422
+ .max(80)
423
+ .optional()
424
+ .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.'),
425
+ evidence: z
426
+ .array(evidenceItemSchema)
427
+ .max(20)
428
+ .optional()
429
+ .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.'),
430
+ },
431
+ }, async ({ title, question, context, timeout_minutes, response_type, options, allow_multiple, proposed_output, tool_name, tool_args, workflow, step, evidence }) => {
270
432
  if (response_type === 'select' && (!options || options.length < 2)) {
271
433
  return asToolResult({
272
434
  error: 'invalid_request',
@@ -274,7 +436,11 @@ server.tool('request_decision', 'Ask the human for a decision or input before pr
274
436
  });
275
437
  }
276
438
  const responseSpec = buildResponseSpec(response_type, options, allow_multiple);
277
- const externalRunId = `mcp-${Date.now()}-${Math.round(Math.random() * 1e6)}`;
439
+ // Minted once per tool invocation and reused as the Idempotency-Key: a
440
+ // retry of *this* call collapses server-side, while a second, independent
441
+ // request_decision — even with identical arguments — gets its own id and is
442
+ // correctly treated as a new ask.
443
+ const externalRunId = `mcp-${randomUUID()}`;
278
444
  // Structured context blocks (FN-83): a tool_call panel when the agent names
279
445
  // the tool it's gating, plus any free-text context. Order = display order.
280
446
  const contextBlocks = [];
@@ -284,29 +450,36 @@ server.tool('request_decision', 'Ask the human for a decision or input before pr
284
450
  if (context) {
285
451
  contextBlocks.push({ kind: 'text', title, text: context });
286
452
  }
287
- const res = await api('/v1/worker/decisions', {
288
- method: 'POST',
289
- body: JSON.stringify({
290
- // The agent is its own "engine" — it polls (check/await) for the
291
- // verdict rather than parking on an engine resume event.
292
- engine: 'mcp',
293
- externalRunId,
294
- stepRef: title.slice(0, 80),
295
- workflowRef: 'ratifia-mcp',
296
- ...(APPROVER_EMAIL ? { assigneeEmail: APPROVER_EMAIL } : {}),
297
- ...(POLICY ? { policy: POLICY } : {}),
298
- required: true,
299
- ...(timeout_minutes ? { timeoutSec: timeout_minutes * 60 } : {}),
300
- ...(responseSpec ? { responseSpec } : {}),
301
- ...(proposed_output
302
- ? { proposedOutput: { title: proposed_output.title, format: 'text', content: proposed_output.content } }
303
- : {}),
304
- context: {
305
- prompt: question,
306
- ...(contextBlocks.length ? { blocks: contextBlocks } : {}),
307
- },
308
- }),
309
- });
453
+ let res;
454
+ try {
455
+ res = await apiRetry(() => api('/v1/worker/decisions', {
456
+ method: 'POST',
457
+ idempotencyKey: externalRunId,
458
+ body: JSON.stringify({
459
+ // The agent is its own "engine" — it polls (check/await) for the
460
+ // verdict rather than parking on an engine resume event.
461
+ engine: 'mcp',
462
+ externalRunId,
463
+ stepRef: label(step, title, 80),
464
+ workflowRef: label(workflow, label(WORKFLOW, 'ratifia-mcp', 100), 100),
465
+ ...(APPROVER_EMAIL ? { assigneeEmail: APPROVER_EMAIL } : {}),
466
+ ...(POLICY ? { policy: POLICY } : {}),
467
+ required: true,
468
+ ...(timeout_minutes ? { timeoutSec: timeout_minutes * 60 } : {}),
469
+ ...(responseSpec ? { responseSpec } : {}),
470
+ ...(proposed_output
471
+ ? { proposedOutput: { title: proposed_output.title, format: 'text', content: proposed_output.content } }
472
+ : {}),
473
+ context: {
474
+ prompt: question,
475
+ ...(contextBlocks.length ? { blocks: contextBlocks } : {}),
476
+ },
477
+ }),
478
+ }));
479
+ }
480
+ catch (err) {
481
+ return asToolError(err);
482
+ }
310
483
  // The policy gate can resolve "no approval needed" — no decision is created.
311
484
  // Tell the agent it may just proceed, rather than returning a bogus id/url.
312
485
  if (res.approvalNeeded === false || !res.decisionId) {
@@ -317,61 +490,135 @@ server.tool('request_decision', 'Ask the human for a decision or input before pr
317
490
  note: 'Ratifia policy did not require a human decision — you may proceed.',
318
491
  });
319
492
  }
493
+ // FN-239: evidence is attached after the decision exists — it hangs off the
494
+ // decision id, so there is nothing to attach to before this point. A failure
495
+ // here is reported, never thrown: the decision is open and the human has
496
+ // already been notified, so aborting would strand it and tempt a duplicate
497
+ // ask. The agent can retry the failed items with attach_evidence.
498
+ const evidenceErrors = [];
499
+ let evidenceAttached = 0;
500
+ for (const item of evidence ?? []) {
501
+ try {
502
+ await apiRetry(() => attachEvidence(res.decisionId, item));
503
+ evidenceAttached++;
504
+ }
505
+ catch (err) {
506
+ evidenceErrors.push(`${item.kind}: ${err instanceof Error ? err.message : String(err)}`);
507
+ }
508
+ }
320
509
  return asToolResult({
321
510
  approval_required: true,
322
511
  decision_id: res.decisionId,
323
512
  status: 'pending',
324
513
  url: `${APP_URL}/decisions/${res.decisionId}`,
514
+ ...(evidence?.length ? { evidence_attached: evidenceAttached } : {}),
515
+ ...(evidenceErrors.length
516
+ ? {
517
+ evidence_errors: evidenceErrors,
518
+ evidence_note: 'The decision was created and the human notified; only these evidence items failed. Retry them with attach_evidence — do NOT request a second decision.',
519
+ }
520
+ : {}),
325
521
  });
326
522
  });
327
- server.tool('check_decision', "Check a Ratifia decision's status ONCE (non-blocking). Returns 'pending' until the human acts, then 'approved'/'rejected' plus their note (their input/answer). Prefer await_decision if you just want to wait for the verdict.", {
328
- decision_id: z.string().describe('The decision_id returned by request_decision.'),
329
- }, async ({ decision_id }) => asToolResult(formatDecision(await getDecision(decision_id))));
330
- server.tool('await_decision', "Block until a Ratifia decision resolves (or max_wait_seconds elapses), so you don't have to poll in a loop. Returns as soon as the human acts ('approved'/'rejected' + their note), or status 'pending' with timed_out=true if still unanswered after the wait — just call await_decision again to keep waiting. Use this after request_decision to wait for the verdict.", {
331
- decision_id: z.string().describe('The decision_id returned by request_decision.'),
332
- max_wait_seconds: z
333
- .number()
334
- .int()
335
- .positive()
336
- .max(240)
337
- .optional()
338
- .describe('How long to block before returning (default 60, max 240). If still pending, call again to keep waiting.'),
523
+ server.registerTool('attach_evidence', {
524
+ description: "Attach material a decision should be judged ON — a Slack conversation, an email, a policy excerpt — to a decision you already opened. Use it when you find something relevant after asking, or when the human asks 'what did the customer actually say?'. Evidence is immutable and append-only, and can only be attached while the decision is still pending: once it resolves the set is sealed, so the record shows exactly what the reviewer could see. Send the text itself, not just a link.",
525
+ inputSchema: {
526
+ decision_id: z.string().describe('The decision_id returned by request_decision.'),
527
+ ...evidenceItemSchema.shape,
528
+ },
529
+ }, async ({ decision_id, ...item }) => {
530
+ try {
531
+ const res = await apiRetry(() => attachEvidence(decision_id, item));
532
+ return asToolResult({
533
+ decision_id,
534
+ evidence_id: res.evidence?.id ?? null,
535
+ // The API dedupes on content hash, so re-sending the same material is a
536
+ // no-op rather than a second panel for the reviewer.
537
+ deduplicated: Boolean(res.deduplicated),
538
+ status: 'attached',
539
+ });
540
+ }
541
+ catch (err) {
542
+ return asToolError(err);
543
+ }
544
+ });
545
+ server.registerTool('check_decision', {
546
+ description: "Check a Ratifia decision's status ONCE (non-blocking). Returns 'pending' until the human acts, then 'approved'/'rejected' plus their note (their input/answer). Prefer await_decision if you just want to wait for the verdict.",
547
+ inputSchema: {
548
+ decision_id: z.string().describe('The decision_id returned by request_decision.'),
549
+ },
550
+ }, async ({ decision_id }) => {
551
+ try {
552
+ return asToolResult(formatDecision(await apiRetry(() => getDecision(decision_id))));
553
+ }
554
+ catch (err) {
555
+ return asToolError(err);
556
+ }
557
+ });
558
+ server.registerTool('await_decision', {
559
+ description: "Block until a Ratifia decision resolves (or max_wait_seconds elapses), so you don't have to poll in a loop. Returns as soon as the human acts ('approved'/'rejected' + their note), or status 'pending' with timed_out=true if still unanswered after the wait — just call await_decision again to keep waiting (that is normal, not a failure). Transient API errors are retried internally; if one still surfaces it comes back as error + retryable rather than throwing, so call again rather than re-requesting the decision. Use this after request_decision to wait for the verdict.",
560
+ inputSchema: {
561
+ decision_id: z.string().describe('The decision_id returned by request_decision.'),
562
+ max_wait_seconds: z
563
+ .number()
564
+ .int()
565
+ .positive()
566
+ .max(110)
567
+ .optional()
568
+ .describe('How long to block before returning (default 90, max 110). Kept under the ~120s at which a harness backgrounds a tool call — a longer wait does not reach you sooner, it just lands late. If still pending, call again to keep waiting.'),
569
+ },
339
570
  }, async ({ decision_id, max_wait_seconds }) => {
340
- const budgetMs = (max_wait_seconds ?? 60) * 1000;
341
- const pollMs = 4000;
342
- const start = Date.now();
343
- let d = await getDecision(decision_id);
344
- while (d.status === 'PENDING' && Date.now() - start + pollMs <= budgetMs) {
345
- await new Promise((r) => setTimeout(r, pollMs));
346
- d = await getDecision(decision_id);
571
+ try {
572
+ const d = await waitForDecision(decision_id, (max_wait_seconds ?? 90) * 1000);
573
+ return asToolResult({
574
+ ...formatDecision(d),
575
+ ...(d.status === 'PENDING' ? { timed_out: true } : {}),
576
+ });
577
+ }
578
+ catch (err) {
579
+ return asToolError(err);
347
580
  }
348
- return asToolResult({
349
- ...formatDecision(d),
350
- ...(d.status === 'PENDING' ? { timed_out: true } : {}),
351
- });
352
581
  });
353
- server.tool('list_pending_decisions', 'List the decisions you have outstanding with Ratifia (default: still-pending), newest first. Use to reconcile after a restart, or to see what you are waiting on before deciding whether to keep waiting or cancel.', {
354
- status: z
355
- .enum(['PENDING', 'APPROVED', 'REJECTED', 'EXPIRED', 'CANCELLED'])
356
- .optional()
357
- .describe('Filter by status. Omit for PENDING (your outstanding asks).'),
358
- limit: z.number().int().positive().max(200).optional().describe('Max to return (default 50).'),
582
+ server.registerTool('list_pending_decisions', {
583
+ description: 'List the decisions you have outstanding with Ratifia (default: still-pending), newest first. Use to reconcile after a restart, or to see what you are waiting on before deciding whether to keep waiting or cancel.',
584
+ inputSchema: {
585
+ status: z
586
+ .enum(['PENDING', 'APPROVED', 'REJECTED', 'EXPIRED', 'CANCELLED'])
587
+ .optional()
588
+ .describe('Filter by status. Omit for PENDING (your outstanding asks).'),
589
+ limit: z.number().int().positive().max(200).optional().describe('Max to return (default 50).'),
590
+ },
359
591
  }, async ({ status, limit }) => {
360
592
  const qs = new URLSearchParams({ status: status ?? 'PENDING' });
361
593
  if (limit)
362
594
  qs.set('limit', String(limit));
363
- const res = await api(`/v1/worker/decisions?${qs.toString()}`, {
364
- method: 'GET',
365
- });
366
- return asToolResult({ count: res.decisions.length, decisions: res.decisions });
595
+ try {
596
+ const res = await apiRetry(() => api(`/v1/worker/decisions?${qs.toString()}`, { method: 'GET' }));
597
+ return asToolResult({ count: res.decisions.length, decisions: res.decisions });
598
+ }
599
+ catch (err) {
600
+ return asToolError(err);
601
+ }
602
+ });
603
+ server.registerTool('cancel_decision', {
604
+ description: 'Withdraw a decision you requested but no longer need (the task was aborted or the question became moot). Resolves it and stops notifying the human. Only PENDING decisions cancel; an already-answered one is returned unchanged (cancelled=false).',
605
+ inputSchema: {
606
+ decision_id: z.string().describe('The decision_id returned by request_decision.'),
607
+ note: z.string().optional().describe('Optional reason, recorded on the decision.'),
608
+ },
609
+ }, async ({ decision_id, note }) => {
610
+ // Cancel is naturally idempotent (only PENDING transitions), so a retry is
611
+ // safe without a key: a second one returns the already-resolved decision.
612
+ try {
613
+ return asToolResult(await apiRetry(() => api(`/v1/worker/decisions/${encodeURIComponent(decision_id)}/cancel`, {
614
+ method: 'POST',
615
+ body: JSON.stringify(note ? { note } : {}),
616
+ })));
617
+ }
618
+ catch (err) {
619
+ return asToolError(err);
620
+ }
367
621
  });
368
- server.tool('cancel_decision', 'Withdraw a decision you requested but no longer need (the task was aborted or the question became moot). Resolves it and stops notifying the human. Only PENDING decisions cancel; an already-answered one is returned unchanged (cancelled=false).', {
369
- decision_id: z.string().describe('The decision_id returned by request_decision.'),
370
- note: z.string().optional().describe('Optional reason, recorded on the decision.'),
371
- }, async ({ decision_id, note }) => asToolResult(await api(`/v1/worker/decisions/${encodeURIComponent(decision_id)}/cancel`, {
372
- method: 'POST',
373
- body: JSON.stringify(note ? { note } : {}),
374
- })));
375
622
  const transport = new StdioServerTransport();
376
623
  await server.connect(transport);
377
624
  console.error(`ratifia-mcp connected → ${API_URL}`);
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@ratifia/mcp",
3
- "version": "0.5.0",
3
+ "version": "0.9.0",
4
4
  "type": "module",
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'.",
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": [
7
7
  "mcp",
8
8
  "model-context-protocol",
@@ -29,7 +29,7 @@
29
29
  "dist"
30
30
  ],
31
31
  "engines": {
32
- "node": ">=18"
32
+ "node": ">=22"
33
33
  },
34
34
  "publishConfig": {
35
35
  "access": "public"
@@ -38,11 +38,12 @@
38
38
  "build": "tsc",
39
39
  "dev": "tsx src/index.ts",
40
40
  "start": "node dist/index.js",
41
- "prepare": "tsc"
41
+ "prepare": "tsc",
42
+ "validate": "node test/validate.mjs"
42
43
  },
43
44
  "dependencies": {
44
- "@modelcontextprotocol/sdk": "^1.12.0",
45
- "zod": "^3.23.0"
45
+ "@modelcontextprotocol/server": "^2.0.0",
46
+ "zod": "^4.4.3"
46
47
  },
47
48
  "devDependencies": {
48
49
  "@types/node": "^20.0.0",