@ratifia/mcp 0.13.2 → 0.14.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 +31 -0
- package/dist/gate.js +1 -1
- package/dist/server.js +76 -4
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -168,6 +168,37 @@ request_decision(
|
|
|
168
168
|
`tool_args` is optional (some tools take none); it's ignored unless `tool_name`
|
|
169
169
|
is set.
|
|
170
170
|
|
|
171
|
+
### Link the records it is about
|
|
172
|
+
|
|
173
|
+
Pass `links` (`{ label, url, description? }`, http(s) only, at most 20) for the
|
|
174
|
+
PR, ticket, customer or invoice. The reviewer gets them as clickable links. A URL
|
|
175
|
+
written inside `context` is shown as plain text, so put links here.
|
|
176
|
+
|
|
177
|
+
### Say how risky it is
|
|
178
|
+
|
|
179
|
+
The reviewer reads severity first. Pass `risk` and the decision page fills its
|
|
180
|
+
"How hard to look", "What changes" and "If it's wrong" sections. Leave it out
|
|
181
|
+
and those show as *not stated*:
|
|
182
|
+
|
|
183
|
+
```
|
|
184
|
+
request_decision(
|
|
185
|
+
title: "Drop legacy_sessions?",
|
|
186
|
+
question: "Drop the table as part of the cleanup migration?",
|
|
187
|
+
tool_name: "run_sql",
|
|
188
|
+
tool_args: { sql: "DROP TABLE legacy_sessions" },
|
|
189
|
+
risk: {
|
|
190
|
+
severity: "high",
|
|
191
|
+
reversible: false,
|
|
192
|
+
impact: [{ label: "Rows deleted", value: "1,240", normal: "0" }],
|
|
193
|
+
if_wrong: "Restore from last night's backup, about 20 minutes."
|
|
194
|
+
}
|
|
195
|
+
)
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
Only `severity` is required. Use real numbers for `impact` ("1,240 rows",
|
|
199
|
+
"$6,000"), not a risk label. Every value is shown to the reviewer as the
|
|
200
|
+
agent's own claim, and an org policy can raise the severity but never lower it.
|
|
201
|
+
|
|
171
202
|
### Evidence — what the decision was made *on*
|
|
172
203
|
|
|
173
204
|
A decision often rests on something outside the ask: the Slack thread where a
|
package/dist/gate.js
CHANGED
|
@@ -172,7 +172,7 @@ export async function runGate(cfg) {
|
|
|
172
172
|
context: {
|
|
173
173
|
prompt: `An agent wants to run the tool "${toolName}"${where ? ` in ${where}` : ''}. Approve?`,
|
|
174
174
|
blocks: [
|
|
175
|
-
{ kind: 'tool_call', title: 'Tool call', tool: toolName, args: toolInput },
|
|
175
|
+
{ kind: 'tool_call', title: 'Tool call', tool: toolName, args: toolInput, role: 'subject', src: 'ai' },
|
|
176
176
|
...(fields.length ? [{ kind: 'fields', title: 'Where', fields }] : []),
|
|
177
177
|
...(askedFor ? [{ kind: 'text', title: 'What the user asked for', text: askedFor }] : []),
|
|
178
178
|
],
|
package/dist/server.js
CHANGED
|
@@ -56,6 +56,49 @@ const evidenceItemSchema = z.object({
|
|
|
56
56
|
.optional()
|
|
57
57
|
.describe('ISO timestamp of when the material was originally written, if it predates now.'),
|
|
58
58
|
});
|
|
59
|
+
/**
|
|
60
|
+
* How hard to look, sent as `context.risk`. Over the MCP the agent is the
|
|
61
|
+
* caller, so every value is its own assessment and is sent as `src: 'ai'` —
|
|
62
|
+
* the reviewer is told so, severity included. A policy floor can still raise it.
|
|
63
|
+
*/
|
|
64
|
+
const riskSchema = z.object({
|
|
65
|
+
severity: z
|
|
66
|
+
.enum(['low', 'medium', 'high'])
|
|
67
|
+
.describe('How hard the reviewer should look. Shown first on every surface.'),
|
|
68
|
+
reversible: z
|
|
69
|
+
.boolean()
|
|
70
|
+
.optional()
|
|
71
|
+
.describe('Whether there is an undo. Omitting it is not "yes": the reviewer is shown "not stated".'),
|
|
72
|
+
impact: z
|
|
73
|
+
.array(z.object({
|
|
74
|
+
label: z.string().min(1).max(120).describe('What is affected, e.g. "Rows deleted", "Refund amount".'),
|
|
75
|
+
value: z
|
|
76
|
+
.string()
|
|
77
|
+
.min(1)
|
|
78
|
+
.max(200)
|
|
79
|
+
.describe('Concrete: "1,240 of 1,300", "$6,000", "2,340 customers". Never a risk label or a confidence score.'),
|
|
80
|
+
normal: z
|
|
81
|
+
.string()
|
|
82
|
+
.max(200)
|
|
83
|
+
.optional()
|
|
84
|
+
.describe('What a normal value looks like, if you know it, e.g. "under $500". Shown beside the value.'),
|
|
85
|
+
}))
|
|
86
|
+
.max(20)
|
|
87
|
+
.optional()
|
|
88
|
+
.describe('What the action touches, one line per thing, with real numbers.'),
|
|
89
|
+
if_wrong: z
|
|
90
|
+
.string()
|
|
91
|
+
.max(2000)
|
|
92
|
+
.optional()
|
|
93
|
+
.describe('What happens if this turns out wrong: the rollback, the clawback, or "no undo". Omitting it tells the reviewer there is none.'),
|
|
94
|
+
});
|
|
95
|
+
/** `risk` as the API stores it, every value marked as the agent's own claim. */
|
|
96
|
+
const toContextRisk = (r) => ({
|
|
97
|
+
severity: { value: r.severity, src: 'ai' },
|
|
98
|
+
...(r.reversible !== undefined ? { reversible: { value: r.reversible, src: 'ai' } } : {}),
|
|
99
|
+
...(r.impact?.length ? { impact: r.impact.map((i) => ({ ...i, src: 'ai' })) } : {}),
|
|
100
|
+
...(r.if_wrong ? { ifWrong: { value: r.if_wrong, src: 'ai' } } : {}),
|
|
101
|
+
});
|
|
59
102
|
function buildResponseSpec(responseType, options, allowMultiple) {
|
|
60
103
|
if (!responseType || responseType === 'approval')
|
|
61
104
|
return undefined;
|
|
@@ -99,7 +142,7 @@ export function createRatifiaServer(cfg, opts) {
|
|
|
99
142
|
'',
|
|
100
143
|
'Workflow:',
|
|
101
144
|
'0. At the start of a run, call list_pending_decisions first: it picks up decisions answered while you were away or before your context was reset.',
|
|
102
|
-
'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.',
|
|
145
|
+
'1. Call request_decision with a short title, a precise question, and enough context to decide (the command/diff/options + tradeoffs). For anything consequential, also pass risk: severity, whether it can be undone, what it touches in real numbers, and what happens if it is wrong. Set timeout_minutes if it is time-sensitive.',
|
|
103
146
|
'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.',
|
|
104
147
|
'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.)',
|
|
105
148
|
'3. Wait for the verdict: prefer await_decision (it blocks until answered) over polling check_decision in a loop. If await_decision returns timed_out=true, call it again; that is normal. Do not end your turn while a decision is still pending unless something will read the verdict back: a verdict does not wake you, so start a background watcher or scheduled wake-up that calls check_decision, or save the decision_id where your next run will find it. Saying "still pending" and stopping drops the task. 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.',
|
|
@@ -110,7 +153,7 @@ export function createRatifiaServer(cfg, opts) {
|
|
|
110
153
|
].join('\n'),
|
|
111
154
|
});
|
|
112
155
|
server.registerTool('request_decision', {
|
|
113
|
-
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.',
|
|
156
|
+
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. To give the reviewer clickable links (a PR, a ticket), pass links: a URL inside context is shown as plain text. 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. For anything consequential, pass risk (severity, reversible, impact, if_wrong): it is the first thing the reviewer reads.',
|
|
114
157
|
inputSchema: {
|
|
115
158
|
title: z.string().describe('Short title, e.g. "Deploy API to prod?"'),
|
|
116
159
|
question: z.string().describe('Exactly what you need the human to decide or provide.'),
|
|
@@ -155,6 +198,22 @@ export function createRatifiaServer(cfg, opts) {
|
|
|
155
198
|
.record(z.string(), z.unknown())
|
|
156
199
|
.optional()
|
|
157
200
|
.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.'),
|
|
201
|
+
links: z
|
|
202
|
+
.array(z.object({
|
|
203
|
+
label: z.string().min(1).max(200).describe('What the link is, e.g. "PR #254" or "Invoice INV-1042".'),
|
|
204
|
+
url: z
|
|
205
|
+
.string()
|
|
206
|
+
.url()
|
|
207
|
+
.regex(/^https?:\/\//i, 'must be an http(s) URL')
|
|
208
|
+
.describe('The http(s) URL.'),
|
|
209
|
+
description: z.string().max(500).optional().describe('One line on why it matters.'),
|
|
210
|
+
}))
|
|
211
|
+
.max(20)
|
|
212
|
+
.optional()
|
|
213
|
+
.describe('The records this decision is about: the PR, the ticket, the customer, the invoice. Shown to the reviewer as clickable links. URLs written inside `context` stay plain text, so put every link the reviewer should open here.'),
|
|
214
|
+
risk: riskSchema
|
|
215
|
+
.optional()
|
|
216
|
+
.describe('How risky the action is: severity, whether it can be undone, what it touches, and what happens if it is wrong. The reviewer reads this first, and a decision without it is shown with severity "not stated". Send it for anything consequential.'),
|
|
158
217
|
approvers: z
|
|
159
218
|
.array(z.string().email())
|
|
160
219
|
.min(1)
|
|
@@ -182,7 +241,7 @@ export function createRatifiaServer(cfg, opts) {
|
|
|
182
241
|
.optional()
|
|
183
242
|
.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.'),
|
|
184
243
|
},
|
|
185
|
-
}, async ({ title, question, context, timeout_minutes, response_type, options, allow_multiple, proposed_output, tool_name, tool_args, approvers, policy, workflow, step, evidence }) => {
|
|
244
|
+
}, async ({ title, question, context, timeout_minutes, response_type, options, allow_multiple, proposed_output, tool_name, tool_args, approvers, policy, workflow, step, evidence, risk, links }) => {
|
|
186
245
|
if (approvers?.length && policy) {
|
|
187
246
|
return asToolResult({
|
|
188
247
|
error: 'invalid_request',
|
|
@@ -208,11 +267,23 @@ export function createRatifiaServer(cfg, opts) {
|
|
|
208
267
|
// the tool it's gating, plus any free-text context. Order = display order.
|
|
209
268
|
const contextBlocks = [];
|
|
210
269
|
if (tool_name) {
|
|
211
|
-
|
|
270
|
+
// `subject`: it is the operation being approved, so the decision page shows
|
|
271
|
+
// it under "What you're approving" rather than in the evidence below.
|
|
272
|
+
contextBlocks.push({
|
|
273
|
+
kind: 'tool_call',
|
|
274
|
+
title: 'Tool call',
|
|
275
|
+
tool: tool_name,
|
|
276
|
+
args: tool_args ?? {},
|
|
277
|
+
role: 'subject',
|
|
278
|
+
src: 'ai',
|
|
279
|
+
});
|
|
212
280
|
}
|
|
213
281
|
if (context) {
|
|
214
282
|
contextBlocks.push({ kind: 'text', title, text: context });
|
|
215
283
|
}
|
|
284
|
+
if (links?.length) {
|
|
285
|
+
contextBlocks.push({ kind: 'links', title: 'Links', links, src: 'ai' });
|
|
286
|
+
}
|
|
216
287
|
let res;
|
|
217
288
|
try {
|
|
218
289
|
res = await apiRetry(() => api('/v1/worker/decisions', {
|
|
@@ -236,6 +307,7 @@ export function createRatifiaServer(cfg, opts) {
|
|
|
236
307
|
context: {
|
|
237
308
|
prompt: question,
|
|
238
309
|
...(contextBlocks.length ? { blocks: contextBlocks } : {}),
|
|
310
|
+
...(risk ? { risk: toContextRisk(risk) } : {}),
|
|
239
311
|
},
|
|
240
312
|
}),
|
|
241
313
|
}));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ratifia/mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Ratifia MCP server — let an AI agent request a human decision/approval (HITL) mid-task and resume once it's answered. The agent becomes a Ratifia 'engine'.",
|
|
6
6
|
"keywords": [
|