@ratifia/mcp 0.4.1 → 0.5.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/dist/index.js +111 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -97,7 +97,117 @@ function buildResponseSpec(responseType, options, allowMultiple) {
|
|
|
97
97
|
max: allowMultiple ? specOptions.length : 1,
|
|
98
98
|
};
|
|
99
99
|
}
|
|
100
|
-
|
|
100
|
+
// ─── Gate mode (Claude Code PreToolUse hook) ─────────────────────────────────
|
|
101
|
+
/** Read the hook payload from stdin (Claude Code pipes the PreToolUse JSON). */
|
|
102
|
+
function readStdin() {
|
|
103
|
+
return new Promise((resolve) => {
|
|
104
|
+
if (process.stdin.isTTY)
|
|
105
|
+
return resolve('');
|
|
106
|
+
let data = '';
|
|
107
|
+
process.stdin.setEncoding('utf8');
|
|
108
|
+
process.stdin.on('data', (c) => (data += c));
|
|
109
|
+
process.stdin.on('end', () => resolve(data));
|
|
110
|
+
setTimeout(() => resolve(data), 3000); // don't hang if the stream never ends
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
/** Emit Claude Code's PreToolUse permission decision (allow | deny) as JSON. */
|
|
114
|
+
function gateDecision(decision, reason) {
|
|
115
|
+
process.stdout.write(JSON.stringify({
|
|
116
|
+
hookSpecificOutput: {
|
|
117
|
+
hookEventName: 'PreToolUse',
|
|
118
|
+
permissionDecision: decision,
|
|
119
|
+
permissionDecisionReason: reason,
|
|
120
|
+
},
|
|
121
|
+
}));
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* `ratifia-mcp gate` — a Claude Code **PreToolUse hook**. Opens a Ratifia
|
|
125
|
+
* decision for the tool call (rendered as a `tool_call` context panel), blocks
|
|
126
|
+
* until a human answers, and returns allow/deny to the harness. The "list of
|
|
127
|
+
* tools" that trigger this is the hook's matcher in settings.json; narrow it
|
|
128
|
+
* further with RATIFIA_GATED_TOOLS. Fail-CLOSED (deny) on error/timeout unless
|
|
129
|
+
* RATIFIA_GATE_FAIL_OPEN=1 — a gate that fails open is no gate.
|
|
130
|
+
*/
|
|
131
|
+
async function runGate() {
|
|
132
|
+
const failOpen = process.env.RATIFIA_GATE_FAIL_OPEN === '1';
|
|
133
|
+
let payload = {};
|
|
134
|
+
try {
|
|
135
|
+
payload = JSON.parse((await readStdin()) || '{}');
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
/* proceed with an empty payload */
|
|
139
|
+
}
|
|
140
|
+
const toolName = String(payload.tool_name ?? 'unknown_tool');
|
|
141
|
+
const toolInput = payload.tool_input ?? {};
|
|
142
|
+
// Optional allow-list: if set, only gate these tools; pass the rest through.
|
|
143
|
+
const gated = (process.env.RATIFIA_GATED_TOOLS ?? '')
|
|
144
|
+
.split(',')
|
|
145
|
+
.map((s) => s.trim())
|
|
146
|
+
.filter(Boolean);
|
|
147
|
+
if (gated.length > 0 && !gated.includes(toolName)) {
|
|
148
|
+
return gateDecision('allow', `not a gated tool: ${toolName}`);
|
|
149
|
+
}
|
|
150
|
+
// How long to block waiting for a human. Keep the settings.json hook `timeout`
|
|
151
|
+
// a little higher than this; on timeout we fail-closed (deny) by default.
|
|
152
|
+
const budgetMs = Math.max(10, Number(process.env.RATIFIA_GATE_TIMEOUT_SEC ?? 240)) * 1000;
|
|
153
|
+
let decisionId;
|
|
154
|
+
try {
|
|
155
|
+
const externalRunId = `gate-${Date.now()}-${Math.round(Math.random() * 1e6)}`;
|
|
156
|
+
const res = await api('/v1/worker/decisions', {
|
|
157
|
+
method: 'POST',
|
|
158
|
+
body: JSON.stringify({
|
|
159
|
+
engine: 'mcp',
|
|
160
|
+
externalRunId,
|
|
161
|
+
stepRef: `tool:${toolName}`.slice(0, 80),
|
|
162
|
+
workflowRef: 'ratifia-gate',
|
|
163
|
+
...(APPROVER_EMAIL ? { assigneeEmail: APPROVER_EMAIL } : {}),
|
|
164
|
+
...(POLICY ? { policy: POLICY } : {}),
|
|
165
|
+
required: true,
|
|
166
|
+
context: {
|
|
167
|
+
prompt: `An agent wants to run the tool "${toolName}". Approve?`,
|
|
168
|
+
blocks: [{ kind: 'tool_call', title: 'Tool call', tool: toolName, args: toolInput }],
|
|
169
|
+
},
|
|
170
|
+
}),
|
|
171
|
+
});
|
|
172
|
+
// Policy gate resolved "no approval needed" → let it run.
|
|
173
|
+
if (res.approvalNeeded === false || !res.decisionId) {
|
|
174
|
+
return gateDecision('allow', 'no approval required by policy');
|
|
175
|
+
}
|
|
176
|
+
decisionId = res.decisionId;
|
|
177
|
+
}
|
|
178
|
+
catch (err) {
|
|
179
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
180
|
+
return gateDecision(failOpen ? 'allow' : 'deny', `ratifia gate unreachable: ${msg}`);
|
|
181
|
+
}
|
|
182
|
+
// Block until the human answers (or the budget elapses → fail-closed).
|
|
183
|
+
const start = Date.now();
|
|
184
|
+
const pollMs = 4000;
|
|
185
|
+
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
|
+
}
|
|
199
|
+
}
|
|
200
|
+
catch (err) {
|
|
201
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
202
|
+
return gateDecision(failOpen ? 'allow' : 'deny', `ratifia gate error: ${msg}`);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
// Dispatch: `ratifia-mcp gate` runs the hook; otherwise start the MCP server.
|
|
206
|
+
if (process.argv[2] === 'gate') {
|
|
207
|
+
await runGate();
|
|
208
|
+
process.exit(0);
|
|
209
|
+
}
|
|
210
|
+
const server = new McpServer({ name: 'ratifia', version: '0.5.0' }, {
|
|
101
211
|
instructions: [
|
|
102
212
|
'Ratifia lets you request a HUMAN decision or input mid-task (human-in-the-loop) and resume once it is answered.',
|
|
103
213
|
'',
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ratifia/mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.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": [
|