@ratifia/mcp 0.12.0 → 0.13.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 +24 -1
- package/dist/client.d.ts +115 -0
- package/dist/client.js +136 -0
- package/dist/gate.d.ts +10 -0
- package/dist/gate.js +212 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +18 -701
- package/dist/server.d.ts +22 -0
- package/dist/server.js +399 -0
- package/package.json +11 -3
package/dist/index.js
CHANGED
|
@@ -36,717 +36,34 @@
|
|
|
36
36
|
* what the dashboard's Workflow filter groups by. Defaults to
|
|
37
37
|
* `ratifia-mcp` (tools) / `ratifia-gate` (the gate hook).
|
|
38
38
|
*/
|
|
39
|
-
import { McpServer } from '@modelcontextprotocol/server';
|
|
40
39
|
import { StdioServerTransport } from '@modelcontextprotocol/server/stdio';
|
|
41
|
-
import {
|
|
42
|
-
import {
|
|
43
|
-
import {
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
const API_KEY = process.env.RATIFIA_API_KEY;
|
|
47
|
-
const APP_URL = (process.env.RATIFIA_APP_URL ?? 'https://app.ratifia.com').replace(/\/+$/, '');
|
|
48
|
-
const APPROVERS = (process.env.RATIFIA_APPROVER_EMAIL ?? '')
|
|
49
|
-
.split(',')
|
|
50
|
-
.map((s) => s.trim())
|
|
51
|
-
.filter(Boolean);
|
|
52
|
-
const POLICY = process.env.RATIFIA_POLICY || undefined;
|
|
53
|
-
/**
|
|
54
|
-
* Who answers (Ratifia PD-003): a policy owns its approvers and expiry, so with
|
|
55
|
-
* one set only its key is sent (approvers alongside it are refused, 422
|
|
56
|
-
* field_owned_by_policy). Without one, the decision is ad hoc to APPROVERS.
|
|
57
|
-
*/
|
|
58
|
-
const WHO = POLICY
|
|
59
|
-
? { policy: { key: POLICY } }
|
|
60
|
-
: APPROVERS.length
|
|
61
|
-
? { approvers: APPROVERS }
|
|
62
|
-
: {};
|
|
63
|
-
const WORKFLOW = process.env.RATIFIA_WORKFLOW;
|
|
64
|
-
/**
|
|
65
|
-
* Where a decision is filed. `workflowRef` is the category (the dashboard's
|
|
66
|
-
* Workflow filter groups by it — e.g. "deploys", "billing-agent");
|
|
67
|
-
* `stepRef` is the specific instance within it. Blank or whitespace-only values
|
|
68
|
-
* fall back rather than filing under an empty name.
|
|
69
|
-
*/
|
|
70
|
-
function label(value, fallback, max) {
|
|
71
|
-
const v = value?.trim();
|
|
72
|
-
return (v ? v : fallback).slice(0, max);
|
|
73
|
-
}
|
|
74
|
-
if (!API_KEY) {
|
|
40
|
+
import { readFileSync } from 'node:fs';
|
|
41
|
+
import { runGate } from './gate.js';
|
|
42
|
+
import { createRatifiaServer } from './server.js';
|
|
43
|
+
const token = process.env.RATIFIA_API_KEY;
|
|
44
|
+
if (!token) {
|
|
75
45
|
console.error('RATIFIA_API_KEY is required');
|
|
76
46
|
process.exit(1);
|
|
77
47
|
}
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
class ApiError extends Error {
|
|
84
|
-
/** null = no response at all (network, DNS, or our own timeout). */
|
|
85
|
-
status;
|
|
86
|
-
/** The API's machine-readable `error` code, when it sent one (e.g. `unknown_policy`). */
|
|
87
|
-
code;
|
|
88
|
-
constructor(message, status, code = null) {
|
|
89
|
-
super(message);
|
|
90
|
-
this.status = status;
|
|
91
|
-
this.code = code;
|
|
92
|
-
}
|
|
93
|
-
get retryable() {
|
|
94
|
-
if (this.status === null)
|
|
95
|
-
return true;
|
|
96
|
-
if (this.status === 408 || this.status === 429)
|
|
97
|
-
return true;
|
|
98
|
-
return this.status >= 500;
|
|
99
|
-
}
|
|
100
|
-
}
|
|
101
|
-
function errorCode(body) {
|
|
102
|
-
try {
|
|
103
|
-
const code = JSON.parse(body).error;
|
|
104
|
-
return typeof code === 'string' ? code : null;
|
|
105
|
-
}
|
|
106
|
-
catch {
|
|
107
|
-
return null;
|
|
108
|
-
}
|
|
109
|
-
}
|
|
110
|
-
/** FN-255: RATIFIA_POLICY names a policy the org doesn't have. A misconfiguration, not an outage. */
|
|
111
|
-
const isUnknownPolicy = (err) => err instanceof ApiError && err.code === 'unknown_policy';
|
|
112
|
-
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.`;
|
|
113
|
-
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
114
|
-
/** `idempotencyKey` makes a retried write safe to collapse server-side; mint it
|
|
115
|
-
* once per tool invocation so two independent asks never share one. */
|
|
116
|
-
async function api(path, init) {
|
|
117
|
-
const { idempotencyKey, ...rest } = init ?? {};
|
|
118
|
-
try {
|
|
119
|
-
const res = await fetch(`${API_URL}${path}`, {
|
|
120
|
-
...rest,
|
|
121
|
-
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
122
|
-
headers: {
|
|
123
|
-
'Content-Type': 'application/json',
|
|
124
|
-
Authorization: `Bearer ${API_KEY}`,
|
|
125
|
-
...(idempotencyKey ? { 'Idempotency-Key': idempotencyKey } : {}),
|
|
126
|
-
...(rest.headers ?? {}),
|
|
127
|
-
},
|
|
128
|
-
});
|
|
129
|
-
const text = await res.text();
|
|
130
|
-
if (!res.ok)
|
|
131
|
-
throw new ApiError(`Ratifia ${path} → ${res.status}: ${text.slice(0, 300)}`, res.status, errorCode(text));
|
|
132
|
-
return (text ? JSON.parse(text) : {});
|
|
133
|
-
}
|
|
134
|
-
catch (err) {
|
|
135
|
-
if (err instanceof ApiError)
|
|
136
|
-
throw err;
|
|
137
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
138
|
-
throw new ApiError(`Ratifia ${path} → unreachable: ${msg}`, null);
|
|
139
|
-
}
|
|
140
|
-
}
|
|
141
|
-
/** Retry transient failures with backoff. One blip used to abort an entire
|
|
142
|
-
* wait, which reads to the agent as "this step failed" — the expensive
|
|
143
|
-
* outcome, since it may then re-ask a human who was already notified. */
|
|
144
|
-
async function apiRetry(fn, attempts = 3) {
|
|
145
|
-
for (let i = 0;; i++) {
|
|
146
|
-
try {
|
|
147
|
-
return await fn();
|
|
148
|
-
}
|
|
149
|
-
catch (err) {
|
|
150
|
-
if (!(err instanceof ApiError) || !err.retryable || i >= attempts - 1)
|
|
151
|
-
throw err;
|
|
152
|
-
await sleep(500 * 2 ** i); // 500ms, then 1s
|
|
153
|
-
}
|
|
154
|
-
}
|
|
155
|
-
}
|
|
156
|
-
const getDecision = (id) => api(`/v1/worker/decisions/${encodeURIComponent(id)}`, { method: 'GET' });
|
|
157
|
-
/** Evidence lives on the dual-auth decisions route, not /worker — an API key
|
|
158
|
-
* authenticates there exactly as a dashboard session does. */
|
|
159
|
-
const attachEvidence = (decisionId, e) => api(`/v1/decisions/${encodeURIComponent(decisionId)}/evidence`, {
|
|
160
|
-
method: 'POST',
|
|
161
|
-
body: JSON.stringify({
|
|
162
|
-
kind: e.kind,
|
|
163
|
-
...(e.title ? { title: e.title } : {}),
|
|
164
|
-
...(e.source_system ? { sourceSystem: e.source_system } : {}),
|
|
165
|
-
...(e.source_url ? { sourceUrl: e.source_url } : {}),
|
|
166
|
-
...(e.body ? { body: e.body } : {}),
|
|
167
|
-
...(e.captured_at ? { capturedAt: e.captured_at } : {}),
|
|
168
|
-
}),
|
|
169
|
-
});
|
|
170
|
-
/** Poll fast while the human is plausibly still at the keyboard, then back off.
|
|
171
|
-
* Most decisions land in the first few seconds, and a flat interval made every
|
|
172
|
-
* one of those wait out the full tick before the agent could move. */
|
|
173
|
-
const pollIntervalMs = (elapsedMs) => elapsedMs < 15_000 ? 1_000 : elapsedMs < 60_000 ? 3_000 : 5_000;
|
|
174
|
-
/** Poll until the decision leaves PENDING or the budget runs out (returns it
|
|
175
|
-
* still PENDING in that case). Transient API failures are retried, not fatal. */
|
|
176
|
-
async function waitForDecision(decisionId, budgetMs) {
|
|
177
|
-
const start = Date.now();
|
|
178
|
-
let d = await apiRetry(() => getDecision(decisionId));
|
|
179
|
-
for (;;) {
|
|
180
|
-
if (d.status !== 'PENDING')
|
|
181
|
-
return d;
|
|
182
|
-
const elapsed = Date.now() - start;
|
|
183
|
-
const wait = pollIntervalMs(elapsed);
|
|
184
|
-
if (elapsed + wait > budgetMs)
|
|
185
|
-
return d;
|
|
186
|
-
await sleep(wait);
|
|
187
|
-
d = await apiRetry(() => getDecision(decisionId));
|
|
188
|
-
}
|
|
189
|
-
}
|
|
190
|
-
/** Agent-facing shape for a decision's current state. */
|
|
191
|
-
const formatDecision = (d) => ({
|
|
192
|
-
decision_id: d.id,
|
|
193
|
-
status: d.status, // PENDING | APPROVED | REJECTED | EXPIRED
|
|
194
|
-
verdict: d.decision, // approve | reject | null
|
|
195
|
-
note: d.note, // the human's free-text input/answer — read and follow it
|
|
196
|
-
// FN-181: for a select/text decision the real answer is here, not in `note`.
|
|
197
|
-
// `response_value.selected` holds the chosen option id(s); map them back to
|
|
198
|
-
// labels via `response_spec.options`. `response_value.text` holds a typed value.
|
|
199
|
-
...(d.responseSpec ? { response_spec: d.responseSpec } : {}),
|
|
200
|
-
...(d.responseValue ? { response_value: d.responseValue } : {}),
|
|
201
|
-
// FN-185: if you sent a proposed_output (e.g. a draft email), the human may
|
|
202
|
-
// have refined it (in the dashboard or a Slack thread). `output.content` is the
|
|
203
|
-
// effective final text to act on — use it over your original draft.
|
|
204
|
-
...(d.output ? { output: d.output, refined: Boolean(d.refinedOutput) } : {}),
|
|
205
|
-
decided_by: d.decidedBy,
|
|
206
|
-
decided_at: d.decidedAt,
|
|
207
|
-
// FN-316: which version of the policy governed this decision, so an agent can
|
|
208
|
-
// cite it ("approved under refunds v3"). Stays put when the policy is edited.
|
|
209
|
-
policy_version: d.policyVersion ?? null,
|
|
210
|
-
policy_version_id: d.policyVersionId ?? null,
|
|
211
|
-
});
|
|
212
|
-
const asToolResult = (payload) => ({
|
|
213
|
-
content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }],
|
|
214
|
-
});
|
|
215
|
-
/** Report an API failure as a structured result rather than an MCP exception,
|
|
216
|
-
* so the agent can retry on `retryable` instead of treating the step as lost
|
|
217
|
-
* and abandoning a decision a human may already be looking at. */
|
|
218
|
-
const asToolError = (err) => isUnknownPolicy(err)
|
|
219
|
-
? asToolResult({ error: 'unknown_policy', policy: POLICY, message: unknownPolicyMessage(), retryable: false })
|
|
220
|
-
: asToolResult({
|
|
221
|
-
error: err instanceof ApiError && err.status ? `http_${err.status}` : 'unreachable',
|
|
222
|
-
message: err instanceof Error ? err.message : String(err),
|
|
223
|
-
retryable: err instanceof ApiError ? err.retryable : true,
|
|
224
|
-
});
|
|
225
|
-
/** FN-239: one piece of evidence, shared by request_decision and attach_evidence. */
|
|
226
|
-
const evidenceItemSchema = z.object({
|
|
227
|
-
kind: z
|
|
228
|
-
.enum(['slack_thread', 'email', 'document', 'link', 'note'])
|
|
229
|
-
.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."),
|
|
230
|
-
title: z.string().optional().describe('Short reviewer-facing label, e.g. "#incidents — refund thread".'),
|
|
231
|
-
source_system: z.string().optional().describe('Where it came from, e.g. "slack", "email", "notion".'),
|
|
232
|
-
source_url: z.string().optional().describe('Deep link back to the original, for provenance.'),
|
|
233
|
-
body: z
|
|
234
|
-
.string()
|
|
235
|
-
.optional()
|
|
236
|
-
.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".'),
|
|
237
|
-
captured_at: z
|
|
238
|
-
.string()
|
|
239
|
-
.optional()
|
|
240
|
-
.describe('ISO timestamp of when the material was originally written, if it predates now.'),
|
|
241
|
-
});
|
|
242
|
-
function buildResponseSpec(responseType, options, allowMultiple) {
|
|
243
|
-
if (!responseType || responseType === 'approval')
|
|
244
|
-
return undefined;
|
|
245
|
-
if (responseType === 'text')
|
|
246
|
-
return { type: 'text' };
|
|
247
|
-
// select
|
|
248
|
-
const seen = new Set();
|
|
249
|
-
const specOptions = (options ?? []).map((o, i) => {
|
|
250
|
-
// Cap at 40: option ids ride in a Discord button custom_id (100-char limit)
|
|
251
|
-
// alongside the 28-char decision id, so keep them short.
|
|
252
|
-
let id = (o.label.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || `opt-${i}`).slice(0, 40);
|
|
253
|
-
while (seen.has(id))
|
|
254
|
-
id = `${id}-${i}`;
|
|
255
|
-
seen.add(id);
|
|
256
|
-
return { id, label: o.label, ...(o.description ? { description: o.description } : {}) };
|
|
257
|
-
});
|
|
258
|
-
return {
|
|
259
|
-
type: 'select',
|
|
260
|
-
options: specOptions,
|
|
261
|
-
min: 1,
|
|
262
|
-
max: allowMultiple ? specOptions.length : 1,
|
|
263
|
-
};
|
|
264
|
-
}
|
|
265
|
-
// ─── Gate mode (Claude Code PreToolUse hook) ─────────────────────────────────
|
|
266
|
-
/** Read the hook payload from stdin (Claude Code pipes the PreToolUse JSON). */
|
|
267
|
-
function readStdin() {
|
|
268
|
-
return new Promise((resolve) => {
|
|
269
|
-
if (process.stdin.isTTY)
|
|
270
|
-
return resolve('');
|
|
271
|
-
let data = '';
|
|
272
|
-
process.stdin.setEncoding('utf8');
|
|
273
|
-
process.stdin.on('data', (c) => (data += c));
|
|
274
|
-
process.stdin.on('end', () => resolve(data));
|
|
275
|
-
setTimeout(() => resolve(data), 3000); // don't hang if the stream never ends
|
|
276
|
-
});
|
|
277
|
-
}
|
|
278
|
-
/** Emit Claude Code's PreToolUse permission decision (allow | deny) as JSON. */
|
|
279
|
-
function gateDecision(decision, reason) {
|
|
280
|
-
process.stdout.write(JSON.stringify({
|
|
281
|
-
hookSpecificOutput: {
|
|
282
|
-
hookEventName: 'PreToolUse',
|
|
283
|
-
permissionDecision: decision,
|
|
284
|
-
permissionDecisionReason: reason,
|
|
285
|
-
},
|
|
286
|
-
}));
|
|
287
|
-
}
|
|
288
|
-
/**
|
|
289
|
-
* Where the agent is working, read straight from `.git` rather than shelling out
|
|
290
|
-
* to git — a hook runs on every gated tool call and must stay cheap.
|
|
291
|
-
*/
|
|
292
|
-
function repoContext(cwd) {
|
|
293
|
-
let dir;
|
|
294
|
-
try {
|
|
295
|
-
dir = resolve(cwd);
|
|
296
|
-
}
|
|
297
|
-
catch {
|
|
298
|
-
return {};
|
|
299
|
-
}
|
|
300
|
-
for (let i = 0; i < 6; i++) {
|
|
301
|
-
try {
|
|
302
|
-
const dotGit = join(dir, '.git');
|
|
303
|
-
if (existsSync(dotGit)) {
|
|
304
|
-
// A worktree's `.git` is a FILE ("gitdir: <path>"); a normal repo's is a
|
|
305
|
-
// directory. Read HEAD from whichever this is.
|
|
306
|
-
const gitDir = statSync(dotGit).isDirectory()
|
|
307
|
-
? dotGit
|
|
308
|
-
: resolve(dir, readFileSync(dotGit, 'utf8').replace(/^gitdir:/, '').trim());
|
|
309
|
-
const head = readFileSync(join(gitDir, 'HEAD'), 'utf8').trim();
|
|
310
|
-
// Keep the whole branch name: "feat/x" is not "x".
|
|
311
|
-
const branch = head.startsWith('ref: refs/heads/')
|
|
312
|
-
? head.slice('ref: refs/heads/'.length)
|
|
313
|
-
: head.startsWith('ref: ')
|
|
314
|
-
? head.slice(head.lastIndexOf('/') + 1)
|
|
315
|
-
: head.slice(0, 7);
|
|
316
|
-
return { repo: basename(dir), branch };
|
|
317
|
-
}
|
|
318
|
-
}
|
|
319
|
-
catch {
|
|
320
|
-
// `.git` is a directory (readFileSync throws EISDIR) or unreadable — the
|
|
321
|
-
// repo name is still the useful half.
|
|
322
|
-
return { repo: basename(dir) };
|
|
323
|
-
}
|
|
324
|
-
const up = dirname(dir);
|
|
325
|
-
if (up === dir)
|
|
326
|
-
break;
|
|
327
|
-
dir = up;
|
|
328
|
-
}
|
|
329
|
-
return { repo: basename(resolve(cwd)) };
|
|
330
|
-
}
|
|
331
|
-
/**
|
|
332
|
-
* The user's most recent request, from the transcript the harness points at.
|
|
333
|
-
* OFF unless RATIFIA_GATE_INCLUDE_PROMPT=1: it is case content, and the gate is
|
|
334
|
-
* otherwise metadata only. The file is JSONL and written asynchronously, so the
|
|
335
|
-
* newest lines may lag; a miss just means no block.
|
|
336
|
-
*/
|
|
337
|
-
function lastUserRequest(transcriptPath) {
|
|
338
|
-
if (process.env.RATIFIA_GATE_INCLUDE_PROMPT !== '1')
|
|
339
|
-
return null;
|
|
340
|
-
if (typeof transcriptPath !== 'string' || !transcriptPath)
|
|
341
|
-
return null;
|
|
342
|
-
try {
|
|
343
|
-
const lines = readFileSync(transcriptPath, 'utf8').split('\n').filter(Boolean);
|
|
344
|
-
for (let i = lines.length - 1; i >= 0; i--) {
|
|
345
|
-
const row = JSON.parse(lines[i]);
|
|
346
|
-
if (row.type !== 'user' && row.message?.role !== 'user')
|
|
347
|
-
continue;
|
|
348
|
-
const content = row.message?.content;
|
|
349
|
-
const text = typeof content === 'string'
|
|
350
|
-
? content
|
|
351
|
-
: Array.isArray(content)
|
|
352
|
-
? content
|
|
353
|
-
.filter((c) => typeof c === 'object' && c !== null && c.type === 'text')
|
|
354
|
-
.map((c) => c.text)
|
|
355
|
-
.join('\n')
|
|
356
|
-
: '';
|
|
357
|
-
const trimmed = text.trim();
|
|
358
|
-
// Skip the harness's own injected turns — they are not what the user asked.
|
|
359
|
-
if (!trimmed || trimmed.startsWith('<'))
|
|
360
|
-
continue;
|
|
361
|
-
return trimmed.length > 1500 ? `${trimmed.slice(0, 1500)}…` : trimmed;
|
|
362
|
-
}
|
|
363
|
-
}
|
|
364
|
-
catch {
|
|
365
|
-
/* unreadable transcript — the gate still works without it */
|
|
366
|
-
}
|
|
367
|
-
return null;
|
|
368
|
-
}
|
|
369
|
-
/**
|
|
370
|
-
* `ratifia-mcp gate` — a Claude Code **PreToolUse hook**. Opens a Ratifia
|
|
371
|
-
* decision for the tool call (rendered as a `tool_call` context panel), blocks
|
|
372
|
-
* until a human answers, and returns allow/deny to the harness. The "list of
|
|
373
|
-
* tools" that trigger this is the hook's matcher in settings.json; narrow it
|
|
374
|
-
* further with RATIFIA_GATED_TOOLS. Fail-CLOSED (deny) on error/timeout unless
|
|
375
|
-
* RATIFIA_GATE_FAIL_OPEN=1 — a gate that fails open is no gate.
|
|
376
|
-
*/
|
|
377
|
-
async function runGate() {
|
|
378
|
-
const failOpen = process.env.RATIFIA_GATE_FAIL_OPEN === '1';
|
|
379
|
-
let payload = {};
|
|
380
|
-
try {
|
|
381
|
-
payload = JSON.parse((await readStdin()) || '{}');
|
|
382
|
-
}
|
|
383
|
-
catch {
|
|
384
|
-
/* proceed with an empty payload */
|
|
385
|
-
}
|
|
386
|
-
const toolName = String(payload.tool_name ?? 'unknown_tool');
|
|
387
|
-
const toolInput = payload.tool_input ?? {};
|
|
388
|
-
// FN: the reviewer sees WHAT would run (the tool call) — give them WHERE and
|
|
389
|
-
// WHO too. All of it comes from the harness payload; none of it is guessed.
|
|
390
|
-
const cwd = typeof payload.cwd === 'string' ? payload.cwd : '';
|
|
391
|
-
const { repo, branch } = cwd ? repoContext(cwd) : {};
|
|
392
|
-
const where = [repo, branch && `(${branch})`].filter(Boolean).join(' ');
|
|
393
|
-
const askedFor = lastUserRequest(payload.transcript_path);
|
|
394
|
-
const fields = [
|
|
395
|
-
repo ? { label: 'Repo', value: branch ? `${repo} (${branch})` : repo } : null,
|
|
396
|
-
cwd ? { label: 'Directory', value: cwd } : null,
|
|
397
|
-
typeof payload.agent_type === 'string' && payload.agent_type ? { label: 'Agent', value: payload.agent_type } : null,
|
|
398
|
-
typeof payload.permission_mode === 'string' && payload.permission_mode
|
|
399
|
-
? { label: 'Permission mode', value: payload.permission_mode }
|
|
400
|
-
: null,
|
|
401
|
-
typeof payload.session_id === 'string' && payload.session_id
|
|
402
|
-
? { label: 'Session', value: payload.session_id.slice(0, 12) }
|
|
403
|
-
: null,
|
|
404
|
-
].filter((f) => f !== null);
|
|
405
|
-
// Optional allow-list: if set, only gate these tools; pass the rest through.
|
|
406
|
-
const gated = (process.env.RATIFIA_GATED_TOOLS ?? '')
|
|
48
|
+
const cfg = {
|
|
49
|
+
apiUrl: process.env.RATIFIA_API_URL ?? 'https://api.ratifia.com',
|
|
50
|
+
token,
|
|
51
|
+
appUrl: process.env.RATIFIA_APP_URL ?? 'https://app.ratifia.com',
|
|
52
|
+
approvers: (process.env.RATIFIA_APPROVER_EMAIL ?? '')
|
|
407
53
|
.split(',')
|
|
408
54
|
.map((s) => s.trim())
|
|
409
|
-
.filter(Boolean)
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
// How long to block waiting for a human. Keep the settings.json hook `timeout`
|
|
414
|
-
// a little higher than this; on timeout we fail-closed (deny) by default.
|
|
415
|
-
const budgetMs = Math.max(10, Number(process.env.RATIFIA_GATE_TIMEOUT_SEC ?? 240)) * 1000;
|
|
416
|
-
let decisionId;
|
|
417
|
-
try {
|
|
418
|
-
// One id per hook invocation: retries below collapse into this decision,
|
|
419
|
-
// while a genuinely separate tool call mints its own and gates separately.
|
|
420
|
-
const externalRunId = `gate-${randomUUID()}`;
|
|
421
|
-
const res = await apiRetry(() => api('/v1/worker/decisions', {
|
|
422
|
-
method: 'POST',
|
|
423
|
-
idempotencyKey: externalRunId,
|
|
424
|
-
body: JSON.stringify({
|
|
425
|
-
engine: 'mcp',
|
|
426
|
-
externalRunId,
|
|
427
|
-
stepRef: `tool:${toolName}`.slice(0, 80),
|
|
428
|
-
workflowRef: label(WORKFLOW, 'ratifia-gate', 100),
|
|
429
|
-
...WHO,
|
|
430
|
-
required: true,
|
|
431
|
-
context: {
|
|
432
|
-
prompt: `An agent wants to run the tool "${toolName}"${where ? ` in ${where}` : ''}. Approve?`,
|
|
433
|
-
blocks: [
|
|
434
|
-
{ kind: 'tool_call', title: 'Tool call', tool: toolName, args: toolInput },
|
|
435
|
-
...(fields.length ? [{ kind: 'fields', title: 'Where', fields }] : []),
|
|
436
|
-
...(askedFor ? [{ kind: 'text', title: 'What the user asked for', text: askedFor }] : []),
|
|
437
|
-
],
|
|
438
|
-
},
|
|
439
|
-
}),
|
|
440
|
-
}));
|
|
441
|
-
// Policy gate resolved "no approval needed" → let it run.
|
|
442
|
-
if (res.approvalNeeded === false || !res.decisionId) {
|
|
443
|
-
return gateDecision('allow', 'no approval required by policy');
|
|
444
|
-
}
|
|
445
|
-
decisionId = res.decisionId;
|
|
446
|
-
}
|
|
447
|
-
catch (err) {
|
|
448
|
-
// A bad RATIFIA_POLICY denies even with fail-open: fail-open is for Ratifia
|
|
449
|
-
// being unreachable, and letting a misconfigured gate wave every tool call
|
|
450
|
-
// through is exactly the outage it exists to prevent.
|
|
451
|
-
if (isUnknownPolicy(err))
|
|
452
|
-
return gateDecision('deny', unknownPolicyMessage());
|
|
453
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
454
|
-
return gateDecision(failOpen ? 'allow' : 'deny', `ratifia gate unreachable: ${msg}`);
|
|
455
|
-
}
|
|
456
|
-
// Block until the human answers (or the budget elapses → fail-closed).
|
|
457
|
-
try {
|
|
458
|
-
const d = await waitForDecision(decisionId, budgetMs);
|
|
459
|
-
if (d.status === 'APPROVED')
|
|
460
|
-
return gateDecision('allow', d.note ?? 'approved by reviewer');
|
|
461
|
-
if (d.status === 'REJECTED')
|
|
462
|
-
return gateDecision('deny', d.note ?? 'rejected by reviewer');
|
|
463
|
-
if (d.status !== 'PENDING')
|
|
464
|
-
return gateDecision('deny', `decision ${d.status.toLowerCase()}`);
|
|
465
|
-
return gateDecision(failOpen ? 'allow' : 'deny', 'timed out waiting for approval');
|
|
466
|
-
}
|
|
467
|
-
catch (err) {
|
|
468
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
469
|
-
return gateDecision(failOpen ? 'allow' : 'deny', `ratifia gate error: ${msg}`);
|
|
470
|
-
}
|
|
471
|
-
}
|
|
55
|
+
.filter(Boolean),
|
|
56
|
+
policy: process.env.RATIFIA_POLICY || undefined,
|
|
57
|
+
workflow: process.env.RATIFIA_WORKFLOW,
|
|
58
|
+
};
|
|
472
59
|
// Dispatch: `ratifia-mcp gate` runs the hook; otherwise start the MCP server.
|
|
473
60
|
if (process.argv[2] === 'gate') {
|
|
474
|
-
await runGate();
|
|
61
|
+
await runGate(cfg);
|
|
475
62
|
process.exit(0);
|
|
476
63
|
}
|
|
477
64
|
/** The published version, read from package.json so the handshake can't drift
|
|
478
65
|
* from npm again (it reported 0.6.0/0.7.0 through several releases). */
|
|
479
66
|
const VERSION = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
|
|
480
|
-
const server =
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
'',
|
|
484
|
-
'When to use request_decision: before a consequential or irreversible action (prod deploy, delete/drop, data migration, spending, sending something externally, credential rotation), or when a requirement is ambiguous and guessing wrong would be costly. Skip it for trivial, reversible, or clearly-authorized steps — that is just friction.',
|
|
485
|
-
'',
|
|
486
|
-
'Workflow:',
|
|
487
|
-
'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.',
|
|
488
|
-
'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.',
|
|
489
|
-
'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.)',
|
|
490
|
-
'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.',
|
|
491
|
-
'4. Do NOT perform the gated action until it resolves approved. On rejected or expired, do not proceed — adapt or ask again.',
|
|
492
|
-
'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.',
|
|
493
|
-
'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.',
|
|
494
|
-
'Prefer one well-scoped decision with rich context over many small ones.',
|
|
495
|
-
].join('\n'),
|
|
496
|
-
});
|
|
497
|
-
server.registerTool('request_decision', {
|
|
498
|
-
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.',
|
|
499
|
-
inputSchema: {
|
|
500
|
-
title: z.string().describe('Short title, e.g. "Deploy API to prod?"'),
|
|
501
|
-
question: z.string().describe('Exactly what you need the human to decide or provide.'),
|
|
502
|
-
context: z
|
|
503
|
-
.string()
|
|
504
|
-
.optional()
|
|
505
|
-
.describe('Details to help them decide: options, tradeoffs, a diff, the command you would run.'),
|
|
506
|
-
timeout_minutes: z
|
|
507
|
-
.number()
|
|
508
|
-
.int()
|
|
509
|
-
.positive()
|
|
510
|
-
.optional()
|
|
511
|
-
.describe("Auto-expire (and reject) if unanswered after N minutes. Omit to wait indefinitely. Ignored when this server is configured with a policy (RATIFIA_POLICY): the policy's expiry applies."),
|
|
512
|
-
response_type: z
|
|
513
|
-
.enum(['approval', 'select', 'text'])
|
|
514
|
-
.optional()
|
|
515
|
-
.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`)."),
|
|
516
|
-
options: z
|
|
517
|
-
.array(z.object({
|
|
518
|
-
label: z.string().describe('What the human sees for this choice.'),
|
|
519
|
-
description: z.string().optional().describe('Optional one-line clarification of the choice.'),
|
|
520
|
-
}))
|
|
521
|
-
.min(2)
|
|
522
|
-
.optional()
|
|
523
|
-
.describe("The choices for a 'select' decision (at least 2). Required when response_type is 'select'."),
|
|
524
|
-
allow_multiple: z
|
|
525
|
-
.boolean()
|
|
526
|
-
.optional()
|
|
527
|
-
.describe("For 'select': allow picking more than one option (default single-choice)."),
|
|
528
|
-
proposed_output: z
|
|
529
|
-
.object({
|
|
530
|
-
title: z.string().optional().describe('Short label for the artifact, e.g. "Email body".'),
|
|
531
|
-
content: z.string().describe('The draft the human can edit before approving (e.g. the email text).'),
|
|
532
|
-
})
|
|
533
|
-
.optional()
|
|
534
|
-
.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.'),
|
|
535
|
-
tool_name: z
|
|
536
|
-
.string()
|
|
537
|
-
.optional()
|
|
538
|
-
.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.'),
|
|
539
|
-
tool_args: z
|
|
540
|
-
.record(z.string(), z.unknown())
|
|
541
|
-
.optional()
|
|
542
|
-
.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.'),
|
|
543
|
-
workflow: z
|
|
544
|
-
.string()
|
|
545
|
-
.max(100)
|
|
546
|
-
.optional()
|
|
547
|
-
.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").'),
|
|
548
|
-
step: z
|
|
549
|
-
.string()
|
|
550
|
-
.max(80)
|
|
551
|
-
.optional()
|
|
552
|
-
.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.'),
|
|
553
|
-
evidence: z
|
|
554
|
-
.array(evidenceItemSchema)
|
|
555
|
-
.max(20)
|
|
556
|
-
.optional()
|
|
557
|
-
.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.'),
|
|
558
|
-
},
|
|
559
|
-
}, async ({ title, question, context, timeout_minutes, response_type, options, allow_multiple, proposed_output, tool_name, tool_args, workflow, step, evidence }) => {
|
|
560
|
-
if (response_type === 'select' && (!options || options.length < 2)) {
|
|
561
|
-
return asToolResult({
|
|
562
|
-
error: 'invalid_request',
|
|
563
|
-
message: "response_type 'select' requires at least 2 options.",
|
|
564
|
-
});
|
|
565
|
-
}
|
|
566
|
-
const responseSpec = buildResponseSpec(response_type, options, allow_multiple);
|
|
567
|
-
// Minted once per tool invocation and reused as the Idempotency-Key: a
|
|
568
|
-
// retry of *this* call collapses server-side, while a second, independent
|
|
569
|
-
// request_decision — even with identical arguments — gets its own id and is
|
|
570
|
-
// correctly treated as a new ask.
|
|
571
|
-
const externalRunId = `mcp-${randomUUID()}`;
|
|
572
|
-
// Structured context blocks (FN-83): a tool_call panel when the agent names
|
|
573
|
-
// the tool it's gating, plus any free-text context. Order = display order.
|
|
574
|
-
const contextBlocks = [];
|
|
575
|
-
if (tool_name) {
|
|
576
|
-
contextBlocks.push({ kind: 'tool_call', title: 'Tool call', tool: tool_name, args: tool_args ?? {} });
|
|
577
|
-
}
|
|
578
|
-
if (context) {
|
|
579
|
-
contextBlocks.push({ kind: 'text', title, text: context });
|
|
580
|
-
}
|
|
581
|
-
let res;
|
|
582
|
-
try {
|
|
583
|
-
res = await apiRetry(() => api('/v1/worker/decisions', {
|
|
584
|
-
method: 'POST',
|
|
585
|
-
idempotencyKey: externalRunId,
|
|
586
|
-
body: JSON.stringify({
|
|
587
|
-
// The agent is its own "engine" — it polls (check/await) for the
|
|
588
|
-
// verdict rather than parking on an engine resume event.
|
|
589
|
-
engine: 'mcp',
|
|
590
|
-
externalRunId,
|
|
591
|
-
stepRef: label(step, title, 80),
|
|
592
|
-
workflowRef: label(workflow, label(WORKFLOW, 'ratifia-mcp', 100), 100),
|
|
593
|
-
...WHO,
|
|
594
|
-
required: true,
|
|
595
|
-
// With a policy, the policy sets the expiry (a timeoutSec would be refused).
|
|
596
|
-
...(timeout_minutes && !POLICY ? { timeoutSec: timeout_minutes * 60 } : {}),
|
|
597
|
-
...(responseSpec ? { responseSpec } : {}),
|
|
598
|
-
...(proposed_output
|
|
599
|
-
? { proposedOutput: { title: proposed_output.title, format: 'text', content: proposed_output.content } }
|
|
600
|
-
: {}),
|
|
601
|
-
context: {
|
|
602
|
-
prompt: question,
|
|
603
|
-
...(contextBlocks.length ? { blocks: contextBlocks } : {}),
|
|
604
|
-
},
|
|
605
|
-
}),
|
|
606
|
-
}));
|
|
607
|
-
}
|
|
608
|
-
catch (err) {
|
|
609
|
-
return asToolError(err);
|
|
610
|
-
}
|
|
611
|
-
// The policy gate can resolve "no approval needed" — no decision is created.
|
|
612
|
-
// Tell the agent it may just proceed, rather than returning a bogus id/url.
|
|
613
|
-
if (res.approvalNeeded === false || !res.decisionId) {
|
|
614
|
-
return asToolResult({
|
|
615
|
-
approval_required: false,
|
|
616
|
-
status: 'not_required',
|
|
617
|
-
reason: res.reason ?? 'policy_gate_not_required',
|
|
618
|
-
note: 'Ratifia policy did not require a human decision — you may proceed.',
|
|
619
|
-
});
|
|
620
|
-
}
|
|
621
|
-
// FN-239: evidence is attached after the decision exists — it hangs off the
|
|
622
|
-
// decision id, so there is nothing to attach to before this point. A failure
|
|
623
|
-
// here is reported, never thrown: the decision is open and the human has
|
|
624
|
-
// already been notified, so aborting would strand it and tempt a duplicate
|
|
625
|
-
// ask. The agent can retry the failed items with attach_evidence.
|
|
626
|
-
const evidenceErrors = [];
|
|
627
|
-
let evidenceAttached = 0;
|
|
628
|
-
for (const item of evidence ?? []) {
|
|
629
|
-
try {
|
|
630
|
-
await apiRetry(() => attachEvidence(res.decisionId, item));
|
|
631
|
-
evidenceAttached++;
|
|
632
|
-
}
|
|
633
|
-
catch (err) {
|
|
634
|
-
evidenceErrors.push(`${item.kind}: ${err instanceof Error ? err.message : String(err)}`);
|
|
635
|
-
}
|
|
636
|
-
}
|
|
637
|
-
return asToolResult({
|
|
638
|
-
approval_required: true,
|
|
639
|
-
decision_id: res.decisionId,
|
|
640
|
-
status: 'pending',
|
|
641
|
-
url: `${APP_URL}/decisions/${res.decisionId}`,
|
|
642
|
-
...(evidence?.length ? { evidence_attached: evidenceAttached } : {}),
|
|
643
|
-
...(evidenceErrors.length
|
|
644
|
-
? {
|
|
645
|
-
evidence_errors: evidenceErrors,
|
|
646
|
-
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.',
|
|
647
|
-
}
|
|
648
|
-
: {}),
|
|
649
|
-
});
|
|
650
|
-
});
|
|
651
|
-
server.registerTool('attach_evidence', {
|
|
652
|
-
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.",
|
|
653
|
-
inputSchema: {
|
|
654
|
-
decision_id: z.string().describe('The decision_id returned by request_decision.'),
|
|
655
|
-
...evidenceItemSchema.shape,
|
|
656
|
-
},
|
|
657
|
-
}, async ({ decision_id, ...item }) => {
|
|
658
|
-
try {
|
|
659
|
-
const res = await apiRetry(() => attachEvidence(decision_id, item));
|
|
660
|
-
return asToolResult({
|
|
661
|
-
decision_id,
|
|
662
|
-
evidence_id: res.evidence?.id ?? null,
|
|
663
|
-
// The API dedupes on content hash, so re-sending the same material is a
|
|
664
|
-
// no-op rather than a second panel for the reviewer.
|
|
665
|
-
deduplicated: Boolean(res.deduplicated),
|
|
666
|
-
status: 'attached',
|
|
667
|
-
});
|
|
668
|
-
}
|
|
669
|
-
catch (err) {
|
|
670
|
-
return asToolError(err);
|
|
671
|
-
}
|
|
672
|
-
});
|
|
673
|
-
server.registerTool('check_decision', {
|
|
674
|
-
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.",
|
|
675
|
-
inputSchema: {
|
|
676
|
-
decision_id: z.string().describe('The decision_id returned by request_decision.'),
|
|
677
|
-
},
|
|
678
|
-
}, async ({ decision_id }) => {
|
|
679
|
-
try {
|
|
680
|
-
return asToolResult(formatDecision(await apiRetry(() => getDecision(decision_id))));
|
|
681
|
-
}
|
|
682
|
-
catch (err) {
|
|
683
|
-
return asToolError(err);
|
|
684
|
-
}
|
|
685
|
-
});
|
|
686
|
-
server.registerTool('await_decision', {
|
|
687
|
-
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.",
|
|
688
|
-
inputSchema: {
|
|
689
|
-
decision_id: z.string().describe('The decision_id returned by request_decision.'),
|
|
690
|
-
max_wait_seconds: z
|
|
691
|
-
.number()
|
|
692
|
-
.int()
|
|
693
|
-
.positive()
|
|
694
|
-
.max(110)
|
|
695
|
-
.optional()
|
|
696
|
-
.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.'),
|
|
697
|
-
},
|
|
698
|
-
}, async ({ decision_id, max_wait_seconds }) => {
|
|
699
|
-
try {
|
|
700
|
-
const d = await waitForDecision(decision_id, (max_wait_seconds ?? 90) * 1000);
|
|
701
|
-
return asToolResult({
|
|
702
|
-
...formatDecision(d),
|
|
703
|
-
...(d.status === 'PENDING' ? { timed_out: true } : {}),
|
|
704
|
-
});
|
|
705
|
-
}
|
|
706
|
-
catch (err) {
|
|
707
|
-
return asToolError(err);
|
|
708
|
-
}
|
|
709
|
-
});
|
|
710
|
-
server.registerTool('list_pending_decisions', {
|
|
711
|
-
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.',
|
|
712
|
-
inputSchema: {
|
|
713
|
-
status: z
|
|
714
|
-
.enum(['PENDING', 'APPROVED', 'REJECTED', 'EXPIRED', 'CANCELLED'])
|
|
715
|
-
.optional()
|
|
716
|
-
.describe('Filter by status. Omit for PENDING (your outstanding asks).'),
|
|
717
|
-
limit: z.number().int().positive().max(200).optional().describe('Max to return (default 50).'),
|
|
718
|
-
},
|
|
719
|
-
}, async ({ status, limit }) => {
|
|
720
|
-
const qs = new URLSearchParams({ status: status ?? 'PENDING' });
|
|
721
|
-
if (limit)
|
|
722
|
-
qs.set('limit', String(limit));
|
|
723
|
-
try {
|
|
724
|
-
const res = await apiRetry(() => api(`/v1/worker/decisions?${qs.toString()}`, { method: 'GET' }));
|
|
725
|
-
return asToolResult({ count: res.decisions.length, decisions: res.decisions });
|
|
726
|
-
}
|
|
727
|
-
catch (err) {
|
|
728
|
-
return asToolError(err);
|
|
729
|
-
}
|
|
730
|
-
});
|
|
731
|
-
server.registerTool('cancel_decision', {
|
|
732
|
-
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).',
|
|
733
|
-
inputSchema: {
|
|
734
|
-
decision_id: z.string().describe('The decision_id returned by request_decision.'),
|
|
735
|
-
note: z.string().optional().describe('Optional reason, recorded on the decision.'),
|
|
736
|
-
},
|
|
737
|
-
}, async ({ decision_id, note }) => {
|
|
738
|
-
// Cancel is naturally idempotent (only PENDING transitions), so a retry is
|
|
739
|
-
// safe without a key: a second one returns the already-resolved decision.
|
|
740
|
-
try {
|
|
741
|
-
return asToolResult(await apiRetry(() => api(`/v1/worker/decisions/${encodeURIComponent(decision_id)}/cancel`, {
|
|
742
|
-
method: 'POST',
|
|
743
|
-
body: JSON.stringify(note ? { note } : {}),
|
|
744
|
-
})));
|
|
745
|
-
}
|
|
746
|
-
catch (err) {
|
|
747
|
-
return asToolError(err);
|
|
748
|
-
}
|
|
749
|
-
});
|
|
750
|
-
const transport = new StdioServerTransport();
|
|
751
|
-
await server.connect(transport);
|
|
752
|
-
console.error(`ratifia-mcp connected → ${API_URL}`);
|
|
67
|
+
const server = createRatifiaServer(cfg, { version: VERSION });
|
|
68
|
+
await server.connect(new StdioServerTransport());
|
|
69
|
+
console.error(`ratifia-mcp connected → ${cfg.apiUrl}`);
|