@ratifia/mcp 0.13.1 → 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 +78 -5
- 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;
|
|
@@ -98,10 +141,11 @@ export function createRatifiaServer(cfg, opts) {
|
|
|
98
141
|
'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.',
|
|
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.',
|
|
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.',
|
|
102
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.',
|
|
103
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.)',
|
|
104
|
-
'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.',
|
|
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.',
|
|
105
149
|
'4. Do NOT perform the gated action until it resolves approved. On rejected or expired, do not proceed — adapt or ask again.',
|
|
106
150
|
'Refining content: to have a human review/edit a draft (e.g. an email you want to send), pass proposed_output.content. They can edit it in the Ratifia app (Slack can only approve or reject it); once approved, use output.content as the final text — it may differ from what you sent.',
|
|
107
151
|
'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.',
|
|
@@ -109,7 +153,7 @@ export function createRatifiaServer(cfg, opts) {
|
|
|
109
153
|
].join('\n'),
|
|
110
154
|
});
|
|
111
155
|
server.registerTool('request_decision', {
|
|
112
|
-
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.',
|
|
113
157
|
inputSchema: {
|
|
114
158
|
title: z.string().describe('Short title, e.g. "Deploy API to prod?"'),
|
|
115
159
|
question: z.string().describe('Exactly what you need the human to decide or provide.'),
|
|
@@ -154,6 +198,22 @@ export function createRatifiaServer(cfg, opts) {
|
|
|
154
198
|
.record(z.string(), z.unknown())
|
|
155
199
|
.optional()
|
|
156
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.'),
|
|
157
217
|
approvers: z
|
|
158
218
|
.array(z.string().email())
|
|
159
219
|
.min(1)
|
|
@@ -181,7 +241,7 @@ export function createRatifiaServer(cfg, opts) {
|
|
|
181
241
|
.optional()
|
|
182
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.'),
|
|
183
243
|
},
|
|
184
|
-
}, 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 }) => {
|
|
185
245
|
if (approvers?.length && policy) {
|
|
186
246
|
return asToolResult({
|
|
187
247
|
error: 'invalid_request',
|
|
@@ -207,11 +267,23 @@ export function createRatifiaServer(cfg, opts) {
|
|
|
207
267
|
// the tool it's gating, plus any free-text context. Order = display order.
|
|
208
268
|
const contextBlocks = [];
|
|
209
269
|
if (tool_name) {
|
|
210
|
-
|
|
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
|
+
});
|
|
211
280
|
}
|
|
212
281
|
if (context) {
|
|
213
282
|
contextBlocks.push({ kind: 'text', title, text: context });
|
|
214
283
|
}
|
|
284
|
+
if (links?.length) {
|
|
285
|
+
contextBlocks.push({ kind: 'links', title: 'Links', links, src: 'ai' });
|
|
286
|
+
}
|
|
215
287
|
let res;
|
|
216
288
|
try {
|
|
217
289
|
res = await apiRetry(() => api('/v1/worker/decisions', {
|
|
@@ -235,6 +307,7 @@ export function createRatifiaServer(cfg, opts) {
|
|
|
235
307
|
context: {
|
|
236
308
|
prompt: question,
|
|
237
309
|
...(contextBlocks.length ? { blocks: contextBlocks } : {}),
|
|
310
|
+
...(risk ? { risk: toContextRisk(risk) } : {}),
|
|
238
311
|
},
|
|
239
312
|
}),
|
|
240
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": [
|