phaedo-mcp 0.1.1 → 0.2.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 +4 -1
- package/calibration.js +107 -0
- package/consult-core.js +363 -36
- package/consult-policy.js +61 -12
- package/consult.js +4 -2
- package/fingerprint-source.js +61 -3
- package/hooks/session-start.mjs +9 -2
- package/install.mjs +6 -0
- package/package.json +11 -4
- package/pairing.js +13 -1
- package/receipts.js +12 -1
- package/sample-fingerprint.json +3 -0
- package/server.js +366 -8
- package/server.json +3 -3
- package/vendor/context-block.js +179 -4
- package/vendor/envelope.js +4 -1
- package/vendor/kdf.js +16 -1
- package/vendor/protocol.js +4 -0
package/server.js
CHANGED
|
@@ -16,11 +16,16 @@ import { z } from 'zod';
|
|
|
16
16
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
17
17
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
18
18
|
|
|
19
|
-
import { loadFingerprint } from './fingerprint-source.js';
|
|
19
|
+
import { loadFingerprint, loadFingerprintPreferCache } from './fingerprint-source.js';
|
|
20
|
+
import { hasPairingRecord, loadPairingRecord } from './phone-source.js';
|
|
21
|
+
import { depositDelegationSuggestions } from './delegation-sync.js';
|
|
22
|
+
import { depositReceiptsDigest, drainAndApplyMarks } from './receipts-sync.js';
|
|
23
|
+
import { buildDelegationPromotions } from './consult-core.js';
|
|
24
|
+
import { depositEscalation, pollEscalationOnce, shouldEscalate, loadEscalationPolicy } from './escalation.js';
|
|
20
25
|
import { buildInjectionResponse, errorResponse, PhaedoError } from './projection.js';
|
|
21
|
-
import { resolveConsultation, checkAuthorization, consultOptsFromEnv, CONSULTATION_TYPES } from './consult.js';
|
|
26
|
+
import { resolveConsultation, checkAuthorization, consultOptsFromEnv, CONSULTATION_TYPES, consultDrivers, deriveDelegationSignals, withDelegationSignals, authoredDelegationSignals } from './consult.js';
|
|
22
27
|
import { defaultStateDir } from './cache.js';
|
|
23
|
-
import { buildReceipt, appendReceipt, receiptsCapFromEnv } from './receipts.js';
|
|
28
|
+
import { buildReceipt, appendReceipt, receiptsCapFromEnv, readReceipts, markOutcome, USER_ACTIONS } from './receipts.js';
|
|
24
29
|
|
|
25
30
|
const BASE_DIR = dirname(fileURLToPath(import.meta.url));
|
|
26
31
|
|
|
@@ -28,16 +33,109 @@ const BASE_DIR = dirname(fileURLToPath(import.meta.url));
|
|
|
28
33
|
// on-device receipt. Written AFTER resolution (the resolver stays pure) and
|
|
29
34
|
// FAIL-SAFE: a receipt-write failure warns on stderr but never breaks the answer.
|
|
30
35
|
const RECEIPTS_CAP = receiptsCapFromEnv(process.env);
|
|
31
|
-
async function emitReceipt(via, args, response) {
|
|
36
|
+
async function emitReceipt(via, args, response, drivers) {
|
|
32
37
|
try {
|
|
33
|
-
const receipt = buildReceipt({ via, agentId: args?.agent_id, request: args, response });
|
|
38
|
+
const receipt = buildReceipt({ via, agentId: args?.agent_id, request: args, response, drivers });
|
|
34
39
|
await appendReceipt(defaultStateDir(), receipt, { cap: RECEIPTS_CAP });
|
|
35
40
|
} catch (e) {
|
|
36
41
|
process.stderr.write(`[phaedo-mcp] receipt write failed (consultation still answered): ${e.message}\n`);
|
|
37
42
|
}
|
|
38
43
|
}
|
|
39
44
|
|
|
45
|
+
// Load the fingerprint for a consult and fold in the act-as-me channel: delegation
|
|
46
|
+
// signals derived from this subject's own override history (§10.6 receipts). The
|
|
47
|
+
// origin-aware resolver then lets a `delegation` correction outrank a `self` signal on
|
|
48
|
+
// the same dimension. Best-effort — if receipts are unreadable, the consult still
|
|
49
|
+
// answers from the plain fingerprint (delegation learning never breaks a consultation).
|
|
50
|
+
async function loadConsultVd(baseDir, { preferCache = false } = {}) {
|
|
51
|
+
// preferCache (escalation): read the at-rest cache instead of a live phone pull, so the
|
|
52
|
+
// escalation doesn't add a fingerprint-read AUTHORIZE prompt on top of its decision card.
|
|
53
|
+
const vd = preferCache ? await loadFingerprintPreferCache(baseDir) : await loadFingerprint(baseDir);
|
|
54
|
+
try {
|
|
55
|
+
// Two delegation sources, both origin-aware: live override history (every override)
|
|
56
|
+
// + the subject's ENDORSED, portable delegation rules (authored_delegation). The
|
|
57
|
+
// authored rule thus shapes consult too, not only the §9 injection it already renders in.
|
|
58
|
+
const signals = deriveDelegationSignals(await readReceipts(defaultStateDir()))
|
|
59
|
+
.concat(authoredDelegationSignals(vd && vd.phaedo_fingerprint));
|
|
60
|
+
return withDelegationSignals(vd, signals);
|
|
61
|
+
} catch (e) {
|
|
62
|
+
process.stderr.write(`[phaedo-mcp] delegation derive skipped (consult unaffected): ${e.message}\n`);
|
|
63
|
+
return vd;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Act-as-me PROMOTION (delegation-sync.js): when the override history shows an
|
|
68
|
+
// ESTABLISHED pattern that isn't already authored/staged, deposit a delegation
|
|
69
|
+
// SUGGESTION across the sync boundary (relay agent_to_ext) for the subject to review +
|
|
70
|
+
// endorse in the extension. Best-effort and isolated — never throws into the consult.
|
|
71
|
+
// Only proposes a suggestion; the subject still authors (the authored-only invariant).
|
|
72
|
+
let _lastDepositSig = null; // dedup: the promo set last deposited THIS process (extension-side
|
|
73
|
+
// endorsement never round-trips into the agent's fp, so without this
|
|
74
|
+
// every consult would re-encrypt + re-POST the identical suggestion)
|
|
75
|
+
// §10.6 receipts audit channel (receipts-sync.js): deposit the latest receipts to the
|
|
76
|
+
// phone's audit slot (agent_to_phone) so the subject can mark outcomes in the flow
|
|
77
|
+
// rather than via `node receipts.js mark`. Dedup on the latest receipt_id +
|
|
78
|
+
// user_action set: identical state → skip the round-trip. Best-effort; a deposit
|
|
79
|
+
// failure NEVER throws into the tool that triggered it.
|
|
80
|
+
let _lastReceiptsSig = null;
|
|
81
|
+
async function maybeDepositReceiptsDigest() {
|
|
82
|
+
try {
|
|
83
|
+
if (!hasPairingRecord(BASE_DIR)) return;
|
|
84
|
+
const receipts = await readReceipts(defaultStateDir());
|
|
85
|
+
if (!receipts.length) return;
|
|
86
|
+
// Signature: latest id + a tally of marked states. Catches "new receipt arrived"
|
|
87
|
+
// AND "an existing receipt got marked" (so the next phone drain reflects the mark).
|
|
88
|
+
const latest = receipts[receipts.length - 1];
|
|
89
|
+
const tally = receipts.reduce((acc, r) => { acc[r.user_action ?? 'null'] = (acc[r.user_action ?? 'null'] || 0) + 1; return acc; }, {});
|
|
90
|
+
const sig = `${latest.receipt_id}|${JSON.stringify(tally)}`;
|
|
91
|
+
if (sig === _lastReceiptsSig) return;
|
|
92
|
+
const res = await depositReceiptsDigest(await loadPairingRecord(BASE_DIR), receipts);
|
|
93
|
+
if (res && res.deposited) _lastReceiptsSig = sig;
|
|
94
|
+
else if (res && !res.deposited && res.reason !== 'no-receipts')
|
|
95
|
+
process.stderr.write(`[phaedo-mcp] receipts digest deposit skipped: ${res.reason || 'http ' + res.status}\n`);
|
|
96
|
+
} catch (e) {
|
|
97
|
+
process.stderr.write(`[phaedo-mcp] receipts digest deposit skipped: ${e.message}\n`);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Drain phone-deposited outcome marks and apply them via markOutcome. Best-effort;
|
|
102
|
+
// called before tools that read receipts (consult uses delegation derivation; the
|
|
103
|
+
// outcome tool may default to "latest") so a recent phone-side mark is reflected
|
|
104
|
+
// before the next operation. Idempotent on the MCP side.
|
|
105
|
+
async function maybePullPhoneMarks() {
|
|
106
|
+
try {
|
|
107
|
+
if (!hasPairingRecord(BASE_DIR)) return { applied: 0, skipped: 0 };
|
|
108
|
+
return await drainAndApplyMarks(await loadPairingRecord(BASE_DIR), defaultStateDir());
|
|
109
|
+
} catch (e) {
|
|
110
|
+
process.stderr.write(`[phaedo-mcp] phone marks drain skipped: ${e.message}\n`);
|
|
111
|
+
return { applied: 0, skipped: 0, reason: e.message };
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function maybeDepositDelegationPromotions(vd) {
|
|
116
|
+
try {
|
|
117
|
+
if (!hasPairingRecord(BASE_DIR)) return; // no extension to deposit to
|
|
118
|
+
const promos = buildDelegationPromotions(await readReceipts(defaultStateDir()), (vd && vd.phaedo_fingerprint) || {});
|
|
119
|
+
if (!promos.length) return;
|
|
120
|
+
const sig = promos.map(p => `${p.decision?.dimension}|${p.decision?.domain || ''}|${p.decision?.value}`).sort().join(',');
|
|
121
|
+
if (sig === _lastDepositSig) return; // identical set already deposited — skip the relay round-trip
|
|
122
|
+
const res = await depositDelegationSuggestions(await loadPairingRecord(BASE_DIR), promos);
|
|
123
|
+
if (res && res.deposited) _lastDepositSig = sig; // latch only on success, so a failed deposit retries next consult
|
|
124
|
+
else if (res && !res.deposited) // promotions existed but didn't land — say why (don't fail silently)
|
|
125
|
+
process.stderr.write(`[phaedo-mcp] ${promos.length} delegation promotion(s) ready but deposit skipped: ${res.reason || 'http ' + res.status}${res.reason === 'no-relay-device-id' ? ' — pairing has no relay lane (local-only pairing)' : ''}\n`);
|
|
126
|
+
} catch (e) {
|
|
127
|
+
process.stderr.write(`[phaedo-mcp] delegation promotion deposit skipped: ${e.message}\n`);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
40
131
|
const CONSULT_OPTS = consultOptsFromEnv(process.env);
|
|
132
|
+
// Real-time escalation window (docs/roadmap/escalation-and-push.md §crux): how long a
|
|
133
|
+
// blocking decision waits for the subject before the agent applies the safe-default
|
|
134
|
+
// (hold). Configurable; bounded by the relay's own MAX_WINDOW_MS.
|
|
135
|
+
const ESCALATION_WINDOW_MS = (() => {
|
|
136
|
+
const n = Number(process.env.PHAEDO_ESCALATION_WINDOW_MS);
|
|
137
|
+
return Number.isFinite(n) && n > 0 ? n : 3 * 60 * 1000;
|
|
138
|
+
})();
|
|
41
139
|
// Deference policies: PHAEDO_CONSULT_POLICIES if set, else the default file beside
|
|
42
140
|
// the server (mcp/consult-policies.json — what policy-editor.html produces). Zero
|
|
43
141
|
// config: drop the file in and restart the client. (Portable vault policies in
|
|
@@ -48,7 +146,18 @@ if (existsSync(POLICIES_PATH)) {
|
|
|
48
146
|
catch (e) { process.stderr.write(`[phaedo-mcp] ignoring malformed consult policies (${POLICIES_PATH}): ${e.message}\n`); }
|
|
49
147
|
}
|
|
50
148
|
|
|
51
|
-
|
|
149
|
+
// Escalation PUSH policy (which signals wake the subject): the subject-configurable knob
|
|
150
|
+
// over the stakes-gated default. Local file (PHAEDO_ESCALATION_POLICY, else
|
|
151
|
+
// mcp/escalation-policy.json beside the server) overrides; the portable vault policy
|
|
152
|
+
// (phaedo_fingerprint.escalation_policy) is merged in per-call (loadEscalationPolicy).
|
|
153
|
+
const ESCALATION_POLICY_PATH = process.env.PHAEDO_ESCALATION_POLICY || join(BASE_DIR, 'escalation-policy.json');
|
|
154
|
+
let ESCALATION_POLICY_FILE = null;
|
|
155
|
+
if (existsSync(ESCALATION_POLICY_PATH)) {
|
|
156
|
+
try { ESCALATION_POLICY_FILE = JSON.parse(readFileSync(ESCALATION_POLICY_PATH, 'utf8')); }
|
|
157
|
+
catch (e) { process.stderr.write(`[phaedo-mcp] ignoring malformed escalation policy (${ESCALATION_POLICY_PATH}): ${e.message}\n`); }
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const server = new McpServer({ name: 'phaedo', version: '0.2.0' });
|
|
52
161
|
|
|
53
162
|
// ── Tool: phaedo_request_injection (binding §3.1) ─────────────────────────────
|
|
54
163
|
server.registerTool(
|
|
@@ -72,6 +181,8 @@ server.registerTool(
|
|
|
72
181
|
requested_layers: z.array(z.string()).optional(),
|
|
73
182
|
consumer_id: z.string().optional(),
|
|
74
183
|
},
|
|
184
|
+
// Read-only: returns the projection, writes nothing. Safe to auto-allow.
|
|
185
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
75
186
|
},
|
|
76
187
|
async (args) => {
|
|
77
188
|
try {
|
|
@@ -109,6 +220,12 @@ server.registerTool(
|
|
|
109
220
|
inputSchema: {
|
|
110
221
|
consultation_type: z.enum(CONSULTATION_TYPES),
|
|
111
222
|
agent_id: z.string().optional(),
|
|
223
|
+
// RESERVED for v0.3 authenticated agent identity (§10.5 enforcement / OQ9).
|
|
224
|
+
// Today `agent_id` is self-asserted and unverified; v0.3 will carry a signed
|
|
225
|
+
// per-agent credential here so a subject can honestly scope `agents`. Declared
|
|
226
|
+
// now (optional, ignored) so the v0.3 addition is backward-compatible — an
|
|
227
|
+
// agent sending it against this server is simply unaffected.
|
|
228
|
+
agent_credential: z.string().optional(),
|
|
112
229
|
action_descriptor: z
|
|
113
230
|
.object({
|
|
114
231
|
domain: z.string().optional(),
|
|
@@ -120,12 +237,21 @@ server.registerTool(
|
|
|
120
237
|
.optional(),
|
|
121
238
|
context: z.record(z.any()).optional(),
|
|
122
239
|
},
|
|
240
|
+
// Not read-only: appends a local §10.6 audit receipt and may best-effort deposit a
|
|
241
|
+
// delegation suggestion. Non-destructive and device-local (no external entities).
|
|
242
|
+
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: false },
|
|
123
243
|
},
|
|
124
244
|
async (args) => {
|
|
125
245
|
try {
|
|
126
|
-
const vd = await
|
|
246
|
+
const vd = await loadConsultVd(BASE_DIR); // fingerprint + act-as-me delegation overlay
|
|
127
247
|
const response = await resolveConsultation(vd, args || {}, CONSULT_OPTS);
|
|
128
|
-
|
|
248
|
+
// Provenance for the local audit receipt only — the cues that drove this
|
|
249
|
+
// (never in `response`; §10.4). Best-effort: a failure must not block the answer.
|
|
250
|
+
let drivers = [];
|
|
251
|
+
try { drivers = consultDrivers(vd, args || {}); } catch { /* provenance is non-critical */ }
|
|
252
|
+
await emitReceipt('phaedo_consult', args, response, drivers); // every resolution, abstains included
|
|
253
|
+
await maybeDepositDelegationPromotions(vd); // act-as-me promotion (best-effort)
|
|
254
|
+
await maybeDepositReceiptsDigest(); // G1+G2 audit channel — refresh the phone's view
|
|
129
255
|
return {
|
|
130
256
|
content: [{ type: 'text', text: JSON.stringify(response, null, 2) }],
|
|
131
257
|
structuredContent: response,
|
|
@@ -160,6 +286,9 @@ server.registerTool(
|
|
|
160
286
|
inputSchema: {
|
|
161
287
|
consultation_type: z.enum(CONSULTATION_TYPES).optional(),
|
|
162
288
|
agent_id: z.string().optional(),
|
|
289
|
+
// RESERVED for v0.3 authenticated agent identity (§10.5) — see phaedo_consult.
|
|
290
|
+
// Optional + ignored today; declared so v0.3 enforcement is additive.
|
|
291
|
+
agent_credential: z.string().optional(),
|
|
163
292
|
action_descriptor: z
|
|
164
293
|
.object({
|
|
165
294
|
domain: z.string().optional(),
|
|
@@ -170,6 +299,8 @@ server.registerTool(
|
|
|
170
299
|
})
|
|
171
300
|
.optional(),
|
|
172
301
|
},
|
|
302
|
+
// Deterministic pre-flight; appends a local audit receipt. Non-destructive, device-local.
|
|
303
|
+
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: false },
|
|
173
304
|
},
|
|
174
305
|
async (args) => {
|
|
175
306
|
try {
|
|
@@ -181,6 +312,7 @@ server.registerTool(
|
|
|
181
312
|
// filling with probes; the follow-up phaedo_consult writes its own).
|
|
182
313
|
if (response.authorization_matched) {
|
|
183
314
|
await emitReceipt('phaedo_check_authorization', req, { signal: response.signal, confidence: 1.0, deference_level: 'high' });
|
|
315
|
+
await maybeDepositReceiptsDigest(); // G1+G2 audit channel — only on a matched preflight (no-match writes no receipt)
|
|
184
316
|
}
|
|
185
317
|
return {
|
|
186
318
|
content: [{ type: 'text', text: JSON.stringify(response, null, 2) }],
|
|
@@ -197,6 +329,232 @@ server.registerTool(
|
|
|
197
329
|
}
|
|
198
330
|
);
|
|
199
331
|
|
|
332
|
+
// ── Tool: phaedo_record_outcome (spec §10.6 — the act-as-me loop closer) ──────
|
|
333
|
+
// After consulting Phaedo and the SUBJECT decided what to do, record their outcome on
|
|
334
|
+
// the consultation receipt. This is the source of delegation learning: a `rejected` or
|
|
335
|
+
// `modified` outcome — the subject CORRECTING an agent that acted on their behalf —
|
|
336
|
+
// teaches Phaedo how they want delegated work handled (a `delegation`-origin signal that
|
|
337
|
+
// outranks their own `self` pattern in future consults). Honesty rail: record what the
|
|
338
|
+
// SUBJECT chose, never whether YOU (the agent) proceeded — `approved` self-reported by an
|
|
339
|
+
// obedient agent teaches nothing, by design, so the agreement metric can't collapse into
|
|
340
|
+
// "agent obedience". §10.4-safe: returns only {matched, receipt_id, user_action}.
|
|
341
|
+
server.registerTool(
|
|
342
|
+
'phaedo_record_outcome',
|
|
343
|
+
{
|
|
344
|
+
title: 'Record what the subject decided about a consulted action',
|
|
345
|
+
description:
|
|
346
|
+
'After you consulted Phaedo and the USER decided what to do, record their outcome on the receipt: ' +
|
|
347
|
+
'approved | rejected | modified | unknown. Record what the USER chose, NOT whether you proceeded. ' +
|
|
348
|
+
'A rejected/modified outcome teaches Phaedo how the user wants delegated work handled (the act-as-me ' +
|
|
349
|
+
'channel). Defaults to the most recent receipt; pass receipt_id to mark a specific one.',
|
|
350
|
+
inputSchema: {
|
|
351
|
+
outcome: z.enum(USER_ACTIONS),
|
|
352
|
+
receipt_id: z.string().optional(),
|
|
353
|
+
// RESERVED for v0.3 learning-loop integrity (OQ16). This is the act-as-me
|
|
354
|
+
// training channel: an adversary inside the same MCP boundary could poison
|
|
355
|
+
// delegation preferences with forged outcome calls. v0.3 will require the
|
|
356
|
+
// recording agent to be authenticated (agent_id + signed agent_credential),
|
|
357
|
+
// rate-limited per dimension, and anomaly-checked. Declared now (optional,
|
|
358
|
+
// ignored) so that enforcement is a backward-compatible addition.
|
|
359
|
+
agent_id: z.string().optional(),
|
|
360
|
+
agent_credential: z.string().optional(),
|
|
361
|
+
},
|
|
362
|
+
// Writes the subject's decision onto a local receipt (the act-as-me learning signal).
|
|
363
|
+
// Non-destructive + idempotent (re-recording the same outcome is a no-op); device-local.
|
|
364
|
+
// Recommend "Always allow" so the learning isn't gated behind a per-call prompt.
|
|
365
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
366
|
+
},
|
|
367
|
+
async (args) => {
|
|
368
|
+
try {
|
|
369
|
+
// Drain any phone-deposited marks first (G1+G2 audit channel): if the subject
|
|
370
|
+
// already marked outcomes from the phone, they should land before the agent's
|
|
371
|
+
// own self-record so "latest" resolves against the freshest state. Best-effort.
|
|
372
|
+
await maybePullPhoneMarks();
|
|
373
|
+
const res = await markOutcome(defaultStateDir(), args?.receipt_id || 'latest', args.outcome);
|
|
374
|
+
// Refresh the phone's view so the just-applied mark is reflected on next drain.
|
|
375
|
+
await maybeDepositReceiptsDigest();
|
|
376
|
+
const body = res.matched
|
|
377
|
+
? { request_type: 'outcome_record', recorded: true, receipt_id: res.receipt_id, user_action: res.user_action }
|
|
378
|
+
: { request_type: 'outcome_record', recorded: false, reason: res.reason };
|
|
379
|
+
return { content: [{ type: 'text', text: JSON.stringify(body, null, 2) }], structuredContent: body };
|
|
380
|
+
} catch (err) {
|
|
381
|
+
const code = err instanceof PhaedoError ? err.code : 'request_malformed';
|
|
382
|
+
return {
|
|
383
|
+
content: [{ type: 'text', text: `Phaedo record-outcome error [${code}]: ${err.message}` }],
|
|
384
|
+
isError: true,
|
|
385
|
+
};
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
);
|
|
389
|
+
|
|
390
|
+
// ── Tool: phaedo_escalate (docs/roadmap/escalation-and-push.md) ───────────────
|
|
391
|
+
// REAL-TIME escalation for an AUTONOMOUS agent: when an agent acting on the subject's
|
|
392
|
+
// behalf hits a decision it can't make alone, wake the subject's phone for a live
|
|
393
|
+
// approve/deny/modify instead of guessing. Self-gating: it runs the consult itself and
|
|
394
|
+
// only wakes the subject when the signal is genuinely blocking (escalate/clarify) — a
|
|
395
|
+
// proceed* signal returns "just proceed", so the subject isn't pinged for low-stakes
|
|
396
|
+
// calls. Non-blocking: deposits + wakes, returns a request_id immediately; the agent
|
|
397
|
+
// then polls phaedo_escalation_status and HOLDS until it resolves (the safe default if
|
|
398
|
+
// the window lapses is do-not-proceed). §10.4-safe: only the agent's own action
|
|
399
|
+
// descriptor + the consult signal/rationale cross the wire (encrypted), never layer
|
|
400
|
+
// content.
|
|
401
|
+
server.registerTool(
|
|
402
|
+
'phaedo_escalate',
|
|
403
|
+
{
|
|
404
|
+
title: 'Escalate a blocking decision to the subject in real time',
|
|
405
|
+
description:
|
|
406
|
+
"When you're acting autonomously for the user and hit a decision you shouldn't make alone, " +
|
|
407
|
+
'escalate it to them live instead of guessing. Runs the consultation and, only if the signal is ' +
|
|
408
|
+
'blocking (escalate/clarify), wakes the user\'s phone for an approve/deny/modify. Returns a ' +
|
|
409
|
+
'request_id immediately — then poll phaedo_escalation_status and DO NOT PROCEED until it resolves ' +
|
|
410
|
+
'(if the user doesn\'t answer in time, hold). Use for consequential, hard-to-undo actions.',
|
|
411
|
+
inputSchema: {
|
|
412
|
+
consultation_type: z.enum(CONSULTATION_TYPES).optional(),
|
|
413
|
+
agent_id: z.string().optional(),
|
|
414
|
+
// RESERVED for v0.3 authenticated agent identity (§10.5) — see phaedo_consult.
|
|
415
|
+
// Optional + ignored today; declared so v0.3 enforcement is additive.
|
|
416
|
+
agent_credential: z.string().optional(),
|
|
417
|
+
action_descriptor: z
|
|
418
|
+
.object({
|
|
419
|
+
domain: z.string().optional(),
|
|
420
|
+
reversible: z.boolean().optional(),
|
|
421
|
+
magnitude: z.enum(['low', 'medium', 'high']).optional(),
|
|
422
|
+
amount: z.number().optional(),
|
|
423
|
+
summary: z.string().optional(),
|
|
424
|
+
})
|
|
425
|
+
.optional(),
|
|
426
|
+
context: z.record(z.any()).optional(),
|
|
427
|
+
},
|
|
428
|
+
// Opens a channel to an EXTERNAL entity (the subject's device, via the relay) and
|
|
429
|
+
// writes a local receipt — not read-only, not destructive, but openWorld (it reaches off-device).
|
|
430
|
+
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
431
|
+
},
|
|
432
|
+
async (args) => {
|
|
433
|
+
try {
|
|
434
|
+
const req = { consultation_type: 'action_approval', ...(args || {}) };
|
|
435
|
+
// Cache-preferred read: escalation shouldn't trigger a fingerprint-read authorize
|
|
436
|
+
// prompt on top of the decision card the subject already answers (one prompt, not two).
|
|
437
|
+
const vd = await loadConsultVd(BASE_DIR, { preferCache: true });
|
|
438
|
+
const response = await resolveConsultation(vd, req, CONSULT_OPTS);
|
|
439
|
+
let drivers = [];
|
|
440
|
+
try { drivers = consultDrivers(vd, req); } catch { /* non-critical */ }
|
|
441
|
+
await emitReceipt('phaedo_escalate', req, response, drivers); // the decision being escalated
|
|
442
|
+
await maybeDepositReceiptsDigest(); // G1+G2 audit channel
|
|
443
|
+
|
|
444
|
+
// Self-gate: only wake the subject for a genuinely blocking signal. `escalate`
|
|
445
|
+
// always; `clarify` per the subject's push policy (default: only when the action is
|
|
446
|
+
// consequential — irreversible / high magnitude). A low-stakes clarify is the
|
|
447
|
+
// agent's to resolve in its own flow.
|
|
448
|
+
const pushPolicy = loadEscalationPolicy(vd, { escalationPolicy: ESCALATION_POLICY_FILE });
|
|
449
|
+
if (!shouldEscalate(response.signal, req.action_descriptor, pushPolicy)) {
|
|
450
|
+
const subThresholdClarify = response.signal === 'clarify';
|
|
451
|
+
const body = {
|
|
452
|
+
request_type: 'escalation', escalated: false, signal: response.signal,
|
|
453
|
+
rationale_hint: response.rationale_hint, confidence: response.confidence,
|
|
454
|
+
...(subThresholdClarify ? { reason: 'clarify_below_threshold' } : {}),
|
|
455
|
+
message: subThresholdClarify
|
|
456
|
+
? 'Low-stakes clarification — handle it in your own flow (ask your clarifying question, or make a reasonable assumption and state it). It will surface in the user\'s review queue; no need to wake them. Raise an explicit escalate if you truly need a live decision.'
|
|
457
|
+
: 'Signal is not blocking — act on it with your own judgment; no need to interrupt the user.',
|
|
458
|
+
};
|
|
459
|
+
return { content: [{ type: 'text', text: JSON.stringify(body, null, 2) }], structuredContent: body };
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
const pairing = await loadPairingRecord(BASE_DIR);
|
|
463
|
+
const dep = await depositEscalation(pairing, req, response, { windowMs: ESCALATION_WINDOW_MS });
|
|
464
|
+
const body = dep.deposited
|
|
465
|
+
? {
|
|
466
|
+
request_type: 'escalation', escalated: true, status: 'pending',
|
|
467
|
+
request_id: dep.request_id, window_ms: dep.window_ms, expires_at: dep.expires_at,
|
|
468
|
+
signal: response.signal, rationale_hint: response.rationale_hint,
|
|
469
|
+
// The relay's push-to-wake result for a BACKGROUNDED phone: { sent, reason }.
|
|
470
|
+
// sent:true → APNs accepted the wake; reason 'not_configured' → relay APNS_* unset,
|
|
471
|
+
// 'no_token'/'not_registered' → phone never registered its APNs token, 'apns_*' →
|
|
472
|
+
// Apple rejected (e.g. BadDeviceToken = sandbox/prod mismatch). A FOREGROUNDED phone
|
|
473
|
+
// is woken over the WS regardless, so a non-sent wake is not necessarily a failure.
|
|
474
|
+
woke: dep.woke,
|
|
475
|
+
message: 'Escalated to the user. Poll phaedo_escalation_status with this request_id; DO NOT PROCEED until it resolves. If it expires unanswered, hold (do not act).',
|
|
476
|
+
}
|
|
477
|
+
: {
|
|
478
|
+
request_type: 'escalation', escalated: false, status: 'unreachable', reason: dep.reason,
|
|
479
|
+
signal: response.signal, rationale_hint: response.rationale_hint,
|
|
480
|
+
message: dep.reason === 'no-relay-device-id' || dep.reason === 'unpaired'
|
|
481
|
+
? 'No real-time channel to the user (no paired device with a relay lane). Hold or fall back to surfacing the decision in your own conversation.'
|
|
482
|
+
: 'Could not reach the user right now. Hold (do not act) or surface the decision in your own conversation.',
|
|
483
|
+
};
|
|
484
|
+
return { content: [{ type: 'text', text: JSON.stringify(body, null, 2) }], structuredContent: body };
|
|
485
|
+
} catch (err) {
|
|
486
|
+
const body = errorResponse(err, 'escalation');
|
|
487
|
+
const code = err instanceof PhaedoError ? err.code : 'internal_error';
|
|
488
|
+
return { content: [{ type: 'text', text: `Phaedo escalation error [${code}]: ${JSON.stringify(body)}` }], isError: true };
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
);
|
|
492
|
+
|
|
493
|
+
// ── Tool: phaedo_escalation_status ────────────────────────────────────────────
|
|
494
|
+
// Poll a pending escalation (from phaedo_escalate). Returns the subject's decision once
|
|
495
|
+
// they answer — approve/deny/modify — and AUTOMATICALLY records it as the §10.6 outcome
|
|
496
|
+
// (roadmap Q7: a real-time approve/deny IS the strongest act-as-me signal, so it feeds the
|
|
497
|
+
// same override-learning loop as phaedo_record_outcome). While unanswered it returns
|
|
498
|
+
// pending (keep holding); past the window it returns expired (apply the safe-default: hold).
|
|
499
|
+
server.registerTool(
|
|
500
|
+
'phaedo_escalation_status',
|
|
501
|
+
{
|
|
502
|
+
title: 'Check whether the subject has decided an escalation',
|
|
503
|
+
description:
|
|
504
|
+
'Poll an escalation you opened with phaedo_escalate. Returns resolved + the decision (approve | deny | ' +
|
|
505
|
+
'modify) once the user answers, pending while you should keep waiting, or expired once the window lapsed ' +
|
|
506
|
+
'(then HOLD — do not proceed). A resolved decision is recorded automatically as the act-as-me outcome.',
|
|
507
|
+
inputSchema: {
|
|
508
|
+
request_id: z.string(),
|
|
509
|
+
expires_at: z.number().optional(),
|
|
510
|
+
receipt_id: z.string().optional(),
|
|
511
|
+
},
|
|
512
|
+
// Reaches the relay (openWorld) and, on resolution, writes the outcome onto a local receipt.
|
|
513
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
514
|
+
},
|
|
515
|
+
async (args) => {
|
|
516
|
+
try {
|
|
517
|
+
const pairing = await loadPairingRecord(BASE_DIR);
|
|
518
|
+
const r = await pollEscalationOnce(pairing, args.request_id, { expiresAt: args.expires_at });
|
|
519
|
+
if (r.resolved) {
|
|
520
|
+
// The live decision IS an outcome — record it on the consult receipt automatically.
|
|
521
|
+
let recorded = false;
|
|
522
|
+
try {
|
|
523
|
+
const m = await markOutcome(defaultStateDir(), args.receipt_id || 'latest', r.outcome);
|
|
524
|
+
recorded = !!(m && m.matched);
|
|
525
|
+
await maybeDepositReceiptsDigest(); // G1+G2: phone sees the recorded outcome on next drain
|
|
526
|
+
} catch { /* outcome-record is best-effort; the decision still returns */ }
|
|
527
|
+
const body = {
|
|
528
|
+
request_type: 'escalation_status', status: 'resolved',
|
|
529
|
+
decision: r.decision, proceed: r.proceed, outcome: r.outcome,
|
|
530
|
+
...(r.note ? { note: r.note } : {}), ...(r.modified != null ? { modified: r.modified } : {}),
|
|
531
|
+
recorded,
|
|
532
|
+
message: !r.proceed
|
|
533
|
+
? 'The user declined — do not proceed.'
|
|
534
|
+
: (r.decision === 'modify'
|
|
535
|
+
? (r.modified != null
|
|
536
|
+
? 'The user approved with changes — proceed using the modified parameters.'
|
|
537
|
+
: `The user wants a change before you proceed${r.note ? `: "${r.note}"` : ''}. Apply their instruction; do not proceed as originally planned.`)
|
|
538
|
+
: 'The user approved — proceed.'),
|
|
539
|
+
};
|
|
540
|
+
return { content: [{ type: 'text', text: JSON.stringify(body, null, 2) }], structuredContent: body };
|
|
541
|
+
}
|
|
542
|
+
const expired = r.status === 'expired';
|
|
543
|
+
const body = {
|
|
544
|
+
request_type: 'escalation_status', status: r.status,
|
|
545
|
+
...(expired ? { proceed: false } : {}),
|
|
546
|
+
message: expired
|
|
547
|
+
? 'The decision window lapsed with no answer. Apply the safe default: hold — do not proceed.'
|
|
548
|
+
: 'Not answered yet. Keep holding; poll again shortly.',
|
|
549
|
+
};
|
|
550
|
+
return { content: [{ type: 'text', text: JSON.stringify(body, null, 2) }], structuredContent: body };
|
|
551
|
+
} catch (err) {
|
|
552
|
+
const code = err instanceof PhaedoError ? err.code : 'request_malformed';
|
|
553
|
+
return { content: [{ type: 'text', text: `Phaedo escalation-status error [${code}]: ${err.message}` }], isError: true };
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
);
|
|
557
|
+
|
|
200
558
|
// ── Resource: phaedo://fingerprint/projection (binding §3.1) ──────────────────
|
|
201
559
|
// Default projection (all layers, standard mode) for resource-only clients.
|
|
202
560
|
server.registerResource(
|
package/server.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
|
|
3
3
|
"name": "io.github.galavoxx/phaedo-mcp",
|
|
4
|
-
"description": "Carry your Phaedo cognitive fingerprint into any MCP client:
|
|
5
|
-
"version": "0.
|
|
4
|
+
"description": "Carry your Phaedo cognitive fingerprint into any MCP client: style injection, decision consults + real-time escalation.",
|
|
5
|
+
"version": "0.2.0",
|
|
6
6
|
"repository": {
|
|
7
7
|
"url": "https://github.com/galavoxx/phaedo-mcp",
|
|
8
8
|
"source": "github"
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
"registryType": "npm",
|
|
13
13
|
"registryBaseUrl": "https://registry.npmjs.org",
|
|
14
14
|
"identifier": "phaedo-mcp",
|
|
15
|
-
"version": "0.
|
|
15
|
+
"version": "0.2.0",
|
|
16
16
|
"transport": {
|
|
17
17
|
"type": "stdio"
|
|
18
18
|
}
|