phaedo-mcp 0.2.2 → 0.3.1
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/NOTICE +17 -0
- package/README.md +182 -19
- package/action-binding.js +82 -0
- package/agreement.js +86 -2
- package/ap2.js +214 -0
- package/autonomy-promotion.js +280 -0
- package/build-package.mjs +25 -10
- package/capture-a11y.js +116 -0
- package/capture-local.js +85 -0
- package/capture-sync.js +99 -0
- package/consult-core.js +126 -8
- package/consult-policy.js +427 -33
- package/consult-runtime.js +60 -0
- package/consult.js +29 -1
- package/delegation-sync.js +95 -0
- package/escalation-policy.sample.json +13 -0
- package/escalation.js +383 -0
- package/fingerprint-source.js +112 -13
- package/health.js +54 -0
- package/hooks/pre-ship.mjs +409 -0
- package/hooks/session-end.mjs +244 -0
- package/hooks/stop-capture.mjs +140 -0
- package/install.mjs +226 -36
- package/lib/capture-home.js +46 -0
- package/lib/domain.js +101 -0
- package/lib/is-main.js +40 -0
- package/lib/phaedo-crypto.js +17 -1
- package/package.json +29 -5
- package/phone-source.js +85 -12
- package/projection.js +4 -1
- package/pull.js +19 -3
- package/receipts-sync.js +197 -0
- package/receipts.js +34 -3
- package/server.js +323 -54
- package/server.json +3 -3
- package/ship-actions.js +164 -0
- package/ship-readout.js +106 -0
- package/signing-audit.js +111 -0
- package/signing-request.js +217 -0
- package/signing.js +176 -0
- package/vendor/authored-source.js +250 -0
- package/vendor/capabilities.js +203 -0
- package/vendor/capture/capture-core.js +67 -0
- package/vendor/capture/detectors.js +287 -0
- package/vendor/capture/exchanges.js +77 -0
- package/vendor/capture/model-detect.js +195 -0
- package/vendor/capture/owner.js +35 -0
- package/vendor/capture/package.json +4 -0
- package/vendor/capture/source-id.js +56 -0
- package/vendor/capture/state.js +148 -0
- package/vendor/capture/transcript.js +184 -0
- package/vendor/commitments.js +169 -0
- package/vendor/context-block.js +251 -18
- package/vendor/envelope.js +50 -4
- package/vendor/extraction/capture-combine.js +160 -0
- package/vendor/extraction/catalog.js +232 -0
- package/vendor/extraction/delta-lift.js +109 -0
- package/vendor/extraction/fingerprint-update.js +227 -0
- package/vendor/extraction/package.json +4 -0
- package/vendor/extraction/persist.js +45 -0
- package/vendor/extraction/reconcile.js +310 -0
- package/vendor/extraction/resolve.js +178 -0
- package/vendor/extraction/rule-promotion.js +103 -0
- package/vendor/kdf.js +20 -1
- package/vendor/protocol.js +7 -0
- package/vendor/signal-ledger.js +402 -0
- package/vendor/trait-phrases.js +656 -0
- package/vendored-files.mjs +86 -0
package/server.js
CHANGED
|
@@ -16,15 +16,20 @@ 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,
|
|
19
|
+
import { loadFingerprint, sourceOf } from './fingerprint-source.js';
|
|
20
20
|
import { hasPairingRecord, loadPairingRecord } from './phone-source.js';
|
|
21
21
|
import { depositDelegationSuggestions } from './delegation-sync.js';
|
|
22
22
|
import { depositReceiptsDigest, drainAndApplyMarks } from './receipts-sync.js';
|
|
23
23
|
import { buildDelegationPromotions } from './consult-core.js';
|
|
24
|
+
import { buildAutonomyPromotions } from './autonomy-promotion.js';
|
|
24
25
|
import { depositEscalation, pollEscalationOnce, shouldEscalate, loadEscalationPolicy } from './escalation.js';
|
|
26
|
+
import { buildIntentMandatePayload } from './ap2.js';
|
|
27
|
+
import { depositSignRequest, pollSignOnce } from './signing-request.js';
|
|
28
|
+
import { buildSigningAuditRecord, appendSigningAudit } from './signing-audit.js';
|
|
25
29
|
import { buildInjectionResponse, errorResponse, PhaedoError } from './projection.js';
|
|
26
|
-
import { resolveConsultation, checkAuthorization,
|
|
30
|
+
import { resolveConsultation, checkAuthorization, CONSULTATION_TYPES, consultDrivers, withRevision } from './consult.js';
|
|
27
31
|
import { defaultStateDir } from './cache.js';
|
|
32
|
+
import { buildConsultOpts, loadConsultVd } from './consult-runtime.js';
|
|
28
33
|
import { buildReceipt, appendReceipt, receiptsCapFromEnv, readReceipts, markOutcome, USER_ACTIONS } from './receipts.js';
|
|
29
34
|
|
|
30
35
|
const BASE_DIR = dirname(fileURLToPath(import.meta.url));
|
|
@@ -35,33 +40,53 @@ const BASE_DIR = dirname(fileURLToPath(import.meta.url));
|
|
|
35
40
|
const RECEIPTS_CAP = receiptsCapFromEnv(process.env);
|
|
36
41
|
async function emitReceipt(via, args, response, drivers) {
|
|
37
42
|
try {
|
|
38
|
-
|
|
43
|
+
// `clientId` is what the audit can actually rely on: the paired client that
|
|
44
|
+
// SERVED this call, versus `agentId` — the vendor string the caller claimed.
|
|
45
|
+
// Recording both is the point; they are believed to different degrees.
|
|
46
|
+
const receipt = buildReceipt({ via, agentId: args?.agent_id, clientId: CONSULT_OPTS.clientId, request: args, response, drivers });
|
|
39
47
|
await appendReceipt(defaultStateDir(), receipt, { cap: RECEIPTS_CAP });
|
|
48
|
+
// RETURN THE HANDLE. The tool descriptions instruct the agent to record what the subject
|
|
49
|
+
// decided, but no tool gave it anything to name — so `phaedo_record_outcome` defaulted to
|
|
50
|
+
// 'latest' and attributed the decision to whatever receipt happened to be newest. A $12k
|
|
51
|
+
// payment rejection landed on an unrelated writing consult. That feeds deriveDelegationSignals
|
|
52
|
+
// and the §10.7 promotion evidence, so a mis-attributed override both erodes caution on the
|
|
53
|
+
// wrong dimension AND removes the contrary evidence that would have disqualified the right one.
|
|
54
|
+
return receipt.receipt_id;
|
|
40
55
|
} catch (e) {
|
|
41
56
|
process.stderr.write(`[phaedo-mcp] receipt write failed (consultation still answered): ${e.message}\n`);
|
|
42
57
|
}
|
|
58
|
+
return null;
|
|
43
59
|
}
|
|
44
60
|
|
|
45
|
-
//
|
|
46
|
-
//
|
|
47
|
-
//
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
61
|
+
// Effects that let an agent act with LESS oversight. Only these are withheld when the read
|
|
62
|
+
// is stale; a restrictive effect keeps applying, because a guardrail the subject may since
|
|
63
|
+
// have removed still fails in the safe direction.
|
|
64
|
+
const EXPANDING_SIGNALS = new Set(['proceed', 'autoproceed', 'bias_bold']);
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Downgrade an authority-EXPANDING answer that was computed from a stale (cached) read.
|
|
68
|
+
* Returns the response unchanged for a live read, or for any answer that already defers.
|
|
69
|
+
* Always annotates provenance so the caller can SAY where the answer came from — the
|
|
70
|
+
* silence is what let a stale "you're authorized" go unnoticed for a day.
|
|
71
|
+
*/
|
|
72
|
+
function withStaleAuthorityGuard(response, vd) {
|
|
73
|
+
const src = sourceOf(vd);
|
|
74
|
+
const out = { ...response };
|
|
75
|
+
const hours = src.ageMs != null ? Math.round(src.ageMs / 3600000) : null;
|
|
76
|
+
out.profile_source = src.live
|
|
77
|
+
? { live: true }
|
|
78
|
+
: { live: false, cached_age_hours: hours, note: 'Answered from a cached profile — the subject\'s device could not be reached.' };
|
|
79
|
+
if (src.live) return out;
|
|
80
|
+
if (!EXPANDING_SIGNALS.has(String(response?.signal))) return out;
|
|
81
|
+
|
|
82
|
+
out.signal = 'escalate';
|
|
83
|
+
out.authorization_matched = false;
|
|
84
|
+
out.stale_downgrade = true;
|
|
85
|
+
out.rationale_hint =
|
|
86
|
+
`Withheld: this would have proceeded automatically, but the decision was computed from a cached copy of the ` +
|
|
87
|
+
`subject's profile${hours != null ? ` about ${hours}h old` : ''} because their device could not be reached. ` +
|
|
88
|
+
`A pre-approval that may since have been revoked must not be acted on unattended — ask the subject.`;
|
|
89
|
+
return out;
|
|
65
90
|
}
|
|
66
91
|
|
|
67
92
|
// Act-as-me PROMOTION (delegation-sync.js): when the override history shows an
|
|
@@ -115,20 +140,32 @@ async function maybePullPhoneMarks() {
|
|
|
115
140
|
async function maybeDepositDelegationPromotions(vd) {
|
|
116
141
|
try {
|
|
117
142
|
if (!hasPairingRecord(BASE_DIR)) return; // no extension to deposit to
|
|
118
|
-
const
|
|
143
|
+
const receipts = await readReceipts(defaultStateDir());
|
|
144
|
+
const fp = (vd && vd.phaedo_fingerprint) || {};
|
|
145
|
+
// Two channels over one deposit. Delegation promotions learn from the subject's
|
|
146
|
+
// DISAGREEMENTS and can only make the agent more deferential; autonomy promotions
|
|
147
|
+
// (§10.7) learn from deliberate, repeated AGREEMENT and are the only channel that
|
|
148
|
+
// can propose less friction — bounded, binding-gated and expiring, and still only
|
|
149
|
+
// a proposal the subject endorses.
|
|
150
|
+
const promos = buildDelegationPromotions(receipts, fp)
|
|
151
|
+
.concat(buildAutonomyPromotions(receipts, fp));
|
|
119
152
|
if (!promos.length) return;
|
|
120
|
-
const sig = promos.map(p =>
|
|
153
|
+
const sig = promos.map(p => p.source === 'autonomy_promotion'
|
|
154
|
+
? `auto|${(p.match?.domains || []).join('+')}|${p.match?.magnitude_max}`
|
|
155
|
+
: `${p.decision?.dimension}|${p.decision?.domain || ''}|${p.decision?.value}`).sort().join(',');
|
|
121
156
|
if (sig === _lastDepositSig) return; // identical set already deposited — skip the relay round-trip
|
|
122
157
|
const res = await depositDelegationSuggestions(await loadPairingRecord(BASE_DIR), promos);
|
|
123
158
|
if (res && res.deposited) _lastDepositSig = sig; // latch only on success, so a failed deposit retries next consult
|
|
124
159
|
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}
|
|
160
|
+
process.stderr.write(`[phaedo-mcp] ${promos.length} rule 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
161
|
} catch (e) {
|
|
127
|
-
process.stderr.write(`[phaedo-mcp]
|
|
162
|
+
process.stderr.write(`[phaedo-mcp] rule promotion deposit skipped: ${e.message}\n`);
|
|
128
163
|
}
|
|
129
164
|
}
|
|
130
165
|
|
|
131
|
-
|
|
166
|
+
// Env config + the subject's deference policies + this install's paired-client id —
|
|
167
|
+
// built in consult-runtime.js, the one copy the Claude Code ship hook shares.
|
|
168
|
+
const CONSULT_OPTS = buildConsultOpts(BASE_DIR, process.env);
|
|
132
169
|
// Real-time escalation window (docs/roadmap/escalation-and-push.md §crux): how long a
|
|
133
170
|
// blocking decision waits for the subject before the agent applies the safe-default
|
|
134
171
|
// (hold). Configurable; bounded by the relay's own MAX_WINDOW_MS.
|
|
@@ -136,15 +173,6 @@ const ESCALATION_WINDOW_MS = (() => {
|
|
|
136
173
|
const n = Number(process.env.PHAEDO_ESCALATION_WINDOW_MS);
|
|
137
174
|
return Number.isFinite(n) && n > 0 ? n : 3 * 60 * 1000;
|
|
138
175
|
})();
|
|
139
|
-
// Deference policies: PHAEDO_CONSULT_POLICIES if set, else the default file beside
|
|
140
|
-
// the server (mcp/consult-policies.json — what policy-editor.html produces). Zero
|
|
141
|
-
// config: drop the file in and restart the client. (Portable vault policies in
|
|
142
|
-
// phaedo_consult_policies always apply too, regardless of this file.)
|
|
143
|
-
const POLICIES_PATH = process.env.PHAEDO_CONSULT_POLICIES || join(BASE_DIR, 'consult-policies.json');
|
|
144
|
-
if (existsSync(POLICIES_PATH)) {
|
|
145
|
-
try { CONSULT_OPTS.policies = JSON.parse(readFileSync(POLICIES_PATH, 'utf8')); }
|
|
146
|
-
catch (e) { process.stderr.write(`[phaedo-mcp] ignoring malformed consult policies (${POLICIES_PATH}): ${e.message}\n`); }
|
|
147
|
-
}
|
|
148
176
|
|
|
149
177
|
// Escalation PUSH policy (which signals wake the subject): the subject-configurable knob
|
|
150
178
|
// over the stakes-gated default. Local file (PHAEDO_ESCALATION_POLICY, else
|
|
@@ -157,7 +185,12 @@ if (existsSync(ESCALATION_POLICY_PATH)) {
|
|
|
157
185
|
catch (e) { process.stderr.write(`[phaedo-mcp] ignoring malformed escalation policy (${ESCALATION_POLICY_PATH}): ${e.message}\n`); }
|
|
158
186
|
}
|
|
159
187
|
|
|
160
|
-
|
|
188
|
+
// Read from package.json rather than a literal: this string was pinned at '0.2.2'
|
|
189
|
+
// while package.json moved on, so the handshake advertised a version that had not
|
|
190
|
+
// been the package's for two months. One source of truth; test-package-closure.mjs
|
|
191
|
+
// asserts the two agree.
|
|
192
|
+
const PKG_VERSION = JSON.parse(readFileSync(join(BASE_DIR, 'package.json'), 'utf8')).version;
|
|
193
|
+
const server = new McpServer({ name: 'phaedo', version: PKG_VERSION });
|
|
161
194
|
|
|
162
195
|
// ── Tool: phaedo_request_injection (binding §3.1) ─────────────────────────────
|
|
163
196
|
server.registerTool(
|
|
@@ -172,6 +205,7 @@ server.registerTool(
|
|
|
172
205
|
session_context: z
|
|
173
206
|
.object({
|
|
174
207
|
platform: z.string().optional(),
|
|
208
|
+
domain: z.string().optional(),
|
|
175
209
|
task_hint: z.string().optional(),
|
|
176
210
|
audience_hint: z.string().optional(),
|
|
177
211
|
max_tokens: z.number().optional(),
|
|
@@ -205,6 +239,24 @@ server.registerTool(
|
|
|
205
239
|
}
|
|
206
240
|
);
|
|
207
241
|
|
|
242
|
+
// §10.2 `action_binding` — the concrete call the action_descriptor claims to
|
|
243
|
+
// describe. Optional and back-compatible: an unbound request resolves exactly as
|
|
244
|
+
// before. Shared by phaedo_consult / phaedo_check_authorization / phaedo_escalate
|
|
245
|
+
// so the three can't drift. See mcp/action-binding.js for why this exists.
|
|
246
|
+
const ACTION_BINDING_SCHEMA = z
|
|
247
|
+
.object({
|
|
248
|
+
tool_name: z.string().describe('The tool/function the agent is about to invoke.'),
|
|
249
|
+
arguments_hash: z.string().describe('Hex or base64url digest over the RFC 8785 canonical form of the arguments. Never the arguments themselves.'),
|
|
250
|
+
arguments_preview: z.string().optional().describe('Optional short human-readable summary of the arguments, shown to the subject on an escalation.'),
|
|
251
|
+
})
|
|
252
|
+
.optional()
|
|
253
|
+
.describe(
|
|
254
|
+
'Binds this consultation to the concrete call it describes. The action_descriptor is your ACCOUNT of the action; ' +
|
|
255
|
+
'this is the action. Recorded on the audit receipt so a later mismatch is provable, shown to the subject when an ' +
|
|
256
|
+
'escalation reaches their phone, and REQUIRED by any standing rule the subject marked require_binding before it ' +
|
|
257
|
+
'will auto-proceed.',
|
|
258
|
+
);
|
|
259
|
+
|
|
208
260
|
// ── Tool: phaedo_consult (binding §3.2, spec §10) ─────────────────────────────
|
|
209
261
|
// Agent consultation: query the fingerprint at a decision point and get back a
|
|
210
262
|
// constrained SIGNAL (§10.3) — never layer content (§10.4 boundary). One tool,
|
|
@@ -214,9 +266,18 @@ server.registerTool(
|
|
|
214
266
|
{
|
|
215
267
|
title: 'Consult the Phaedo fingerprint at a decision point',
|
|
216
268
|
description:
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
269
|
+
// Names the MOMENTS. "Use at agent decision points" told no agent when it was at one: not
|
|
270
|
+
// one consultation was written between 2026-07-28 and 2026-10-18. And it closes the loop —
|
|
271
|
+
// the receipt_id is the only handle that attributes the user's reaction to THIS action.
|
|
272
|
+
'Ask how the user would want an action handled, before you take it on their behalf — without ' +
|
|
273
|
+
'reading their profile. Call it BEFORE any consequential action you are about to take for the ' +
|
|
274
|
+
'user: merging or deploying, publishing or posting, sending a message, paying or committing money, ' +
|
|
275
|
+
'deleting, or changing an account or setting. Describe the action in action_descriptor (domain, ' +
|
|
276
|
+
'reversible, magnitude, a short summary; amount when money moves) and bind it to the exact call ' +
|
|
277
|
+
'with action_binding. Returns a signal (proceed | proceed_with_note | clarify | escalate | decline | ' +
|
|
278
|
+
'insufficient_signal), a confidence, a short rationale_hint, a deference_level (how strongly to ' +
|
|
279
|
+
'weight it) and a receipt_id. Keep the receipt_id: when the user reacts to what you did — confirms ' +
|
|
280
|
+
'it, overrides it, or wants it done differently — record that with phaedo_record_outcome.',
|
|
220
281
|
inputSchema: {
|
|
221
282
|
consultation_type: z.enum(CONSULTATION_TYPES),
|
|
222
283
|
agent_id: z.string().optional(),
|
|
@@ -235,6 +296,7 @@ server.registerTool(
|
|
|
235
296
|
summary: z.string().optional(),
|
|
236
297
|
})
|
|
237
298
|
.optional(),
|
|
299
|
+
action_binding: ACTION_BINDING_SCHEMA,
|
|
238
300
|
context: z.record(z.any()).optional(),
|
|
239
301
|
},
|
|
240
302
|
// Not read-only: appends a local §10.6 audit receipt and may best-effort deposit a
|
|
@@ -244,12 +306,18 @@ server.registerTool(
|
|
|
244
306
|
async (args) => {
|
|
245
307
|
try {
|
|
246
308
|
const vd = await loadConsultVd(BASE_DIR); // fingerprint + act-as-me delegation overlay
|
|
247
|
-
|
|
309
|
+
// STALE-AUTHORITY GUARD. This used to be applied to phaedo_check_authorization ALONE, so
|
|
310
|
+
// the two tools an autonomous agent actually uses answered from a possibly-revoked cached
|
|
311
|
+
// profile with no downgrade and no provenance. That is the 2026-07-25 device defect (a
|
|
312
|
+
// revoked $500 grant honoured from a 22h cache) still live on the paths that matter most.
|
|
313
|
+
const raw = await resolveConsultation(vd, args || {}, CONSULT_OPTS);
|
|
314
|
+
const response = withStaleAuthorityGuard(raw, vd);
|
|
248
315
|
// Provenance for the local audit receipt only — the cues that drove this
|
|
249
316
|
// (never in `response`; §10.4). Best-effort: a failure must not block the answer.
|
|
250
317
|
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
|
|
318
|
+
try { drivers = withRevision(consultDrivers(vd, args || {}, CONSULT_OPTS), raw); } catch { /* provenance is non-critical */ }
|
|
319
|
+
const receiptId = await emitReceipt('phaedo_consult', args, response, drivers); // every resolution, abstains included
|
|
320
|
+
if (receiptId) response.receipt_id = receiptId; // the handle for phaedo_record_outcome
|
|
253
321
|
await maybeDepositDelegationPromotions(vd); // act-as-me promotion (best-effort)
|
|
254
322
|
await maybeDepositReceiptsDigest(); // G1+G2 audit channel — refresh the phone's view
|
|
255
323
|
return {
|
|
@@ -298,6 +366,7 @@ server.registerTool(
|
|
|
298
366
|
summary: z.string().optional(),
|
|
299
367
|
})
|
|
300
368
|
.optional(),
|
|
369
|
+
action_binding: ACTION_BINDING_SCHEMA,
|
|
301
370
|
},
|
|
302
371
|
// Deterministic pre-flight; appends a local audit receipt. Non-destructive, device-local.
|
|
303
372
|
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: false },
|
|
@@ -306,12 +375,26 @@ server.registerTool(
|
|
|
306
375
|
try {
|
|
307
376
|
const req = { consultation_type: 'action_approval', ...(args || {}) };
|
|
308
377
|
const vd = await loadFingerprint(BASE_DIR);
|
|
309
|
-
|
|
378
|
+
// STALENESS MAY NOT EXPAND AUTONOMY. When the live pull failed we are answering from
|
|
379
|
+
// an at-rest cache that predates anything the subject has changed since — including a
|
|
380
|
+
// REVOCATION. Observed on device 2026-07-25: a revoked $500 payment grant kept
|
|
381
|
+
// returning "authorized" from a 22-hour-old copy. A stale read may still make an agent
|
|
382
|
+
// MORE deferential (that direction is safe and useful phone-absent); it may never be
|
|
383
|
+
// the basis for proceeding unattended. Same asymmetry as `expires_at`, `require_binding`
|
|
384
|
+
// and the §10.2 domain aliases: narrow freely, widen never.
|
|
385
|
+
// The receipt is gated on `authorization_matched`, which the guard CLEARS — so the one
|
|
386
|
+
// case that most needs an audit trail (an authorization fired against a possibly-revoked
|
|
387
|
+
// cached grant and had to be withheld) used to leave no record at all. §10.6: "a matched
|
|
388
|
+
// deterministic pre-flight is a resolution and produces a receipt."
|
|
389
|
+
const preGuard = checkAuthorization(vd, req, CONSULT_OPTS);
|
|
390
|
+
const matchedPreGuard = preGuard.authorization_matched === true;
|
|
391
|
+
const response = withStaleAuthorityGuard(preGuard, vd);
|
|
310
392
|
// A matched pre-flight IS a resolved decision (the authorization fired) →
|
|
311
393
|
// receipt. A no-match is not a decision — no receipt (keeps the store from
|
|
312
394
|
// filling with probes; the follow-up phaedo_consult writes its own).
|
|
313
|
-
if (
|
|
314
|
-
await emitReceipt('phaedo_check_authorization', req, { signal: response.signal, confidence: 1.0, deference_level: 'high' });
|
|
395
|
+
if (matchedPreGuard) {
|
|
396
|
+
const rid = await emitReceipt('phaedo_check_authorization', req, { signal: response.signal, confidence: 1.0, deference_level: 'high' });
|
|
397
|
+
if (rid) response.receipt_id = rid;
|
|
315
398
|
await maybeDepositReceiptsDigest(); // G1+G2 audit channel — only on a matched preflight (no-match writes no receipt)
|
|
316
399
|
}
|
|
317
400
|
return {
|
|
@@ -343,10 +426,13 @@ server.registerTool(
|
|
|
343
426
|
{
|
|
344
427
|
title: 'Record what the subject decided about a consulted action',
|
|
345
428
|
description:
|
|
346
|
-
'
|
|
347
|
-
'
|
|
348
|
-
'
|
|
349
|
-
'
|
|
429
|
+
'Record what the USER decided about an action you consulted Phaedo on: approved | rejected | ' +
|
|
430
|
+
'modified | unknown. Call it when the user reacts to that action — they confirm it (approved), ' +
|
|
431
|
+
'refuse or undo it (rejected), or want it done differently (modified). Always pass the receipt_id ' +
|
|
432
|
+
'that phaedo_consult (or a Phaedo hook) gave you; without it the most recent receipt is marked, ' +
|
|
433
|
+
'which may be a different action. Record what the user chose, never whether you proceeded: ' +
|
|
434
|
+
'silence is not approval, and an approval you inferred teaches nothing. A rejected/modified ' +
|
|
435
|
+
'outcome teaches Phaedo how the user wants delegated work handled (the act-as-me channel).',
|
|
350
436
|
inputSchema: {
|
|
351
437
|
outcome: z.enum(USER_ACTIONS),
|
|
352
438
|
receipt_id: z.string().optional(),
|
|
@@ -423,6 +509,7 @@ server.registerTool(
|
|
|
423
509
|
summary: z.string().optional(),
|
|
424
510
|
})
|
|
425
511
|
.optional(),
|
|
512
|
+
action_binding: ACTION_BINDING_SCHEMA,
|
|
426
513
|
context: z.record(z.any()).optional(),
|
|
427
514
|
},
|
|
428
515
|
// Opens a channel to an EXTERNAL entity (the subject's device, via the relay) and
|
|
@@ -435,10 +522,15 @@ server.registerTool(
|
|
|
435
522
|
// Cache-preferred read: escalation shouldn't trigger a fingerprint-read authorize
|
|
436
523
|
// prompt on top of the decision card the subject already answers (one prompt, not two).
|
|
437
524
|
const vd = await loadConsultVd(BASE_DIR, { preferCache: true });
|
|
438
|
-
|
|
525
|
+
// Guarded like the others, and it needs it MOST: this path reads the cache
|
|
526
|
+
// UNCONDITIONALLY, so without the guard it always reasoned from a possibly-stale profile
|
|
527
|
+
// and then told the agent the signal was not blocking — i.e. "just proceed", and never
|
|
528
|
+
// wake the subject, on authority that may have been revoked hours earlier.
|
|
529
|
+
const raw = await resolveConsultation(vd, req, CONSULT_OPTS);
|
|
530
|
+
const response = withStaleAuthorityGuard(raw, vd);
|
|
439
531
|
let drivers = [];
|
|
440
|
-
try { drivers = consultDrivers(vd, req); } catch { /* non-critical */ }
|
|
441
|
-
await emitReceipt('phaedo_escalate', req, response, drivers); // the decision being escalated
|
|
532
|
+
try { drivers = withRevision(consultDrivers(vd, req, CONSULT_OPTS), raw); } catch { /* non-critical */ }
|
|
533
|
+
const escReceiptId = await emitReceipt('phaedo_escalate', req, response, drivers); // the decision being escalated
|
|
442
534
|
await maybeDepositReceiptsDigest(); // G1+G2 audit channel
|
|
443
535
|
|
|
444
536
|
// Self-gate: only wake the subject for a genuinely blocking signal. `escalate`
|
|
@@ -451,6 +543,7 @@ server.registerTool(
|
|
|
451
543
|
const body = {
|
|
452
544
|
request_type: 'escalation', escalated: false, signal: response.signal,
|
|
453
545
|
rationale_hint: response.rationale_hint, confidence: response.confidence,
|
|
546
|
+
...(escReceiptId ? { receipt_id: escReceiptId } : {}),
|
|
454
547
|
...(subThresholdClarify ? { reason: 'clarify_below_threshold' } : {}),
|
|
455
548
|
message: subThresholdClarify
|
|
456
549
|
? '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.'
|
|
@@ -464,6 +557,11 @@ server.registerTool(
|
|
|
464
557
|
const body = dep.deposited
|
|
465
558
|
? {
|
|
466
559
|
request_type: 'escalation', escalated: true, status: 'pending',
|
|
560
|
+
// Both handles. `request_id` correlates the live decision; `receipt_id` names the
|
|
561
|
+
// §10.6 record to mark afterwards. Returning only the first meant
|
|
562
|
+
// phaedo_escalation_status could not mark correctly and a real-time approve/deny was
|
|
563
|
+
// auto-recorded onto whatever receipt happened to be newest.
|
|
564
|
+
...(escReceiptId ? { receipt_id: escReceiptId } : {}),
|
|
467
565
|
request_id: dep.request_id, window_ms: dep.window_ms, expires_at: dep.expires_at,
|
|
468
566
|
signal: response.signal, rationale_hint: response.rationale_hint,
|
|
469
567
|
// The relay's push-to-wake result for a BACKGROUNDED phone: { sent, reason }.
|
|
@@ -555,6 +653,177 @@ server.registerTool(
|
|
|
555
653
|
}
|
|
556
654
|
);
|
|
557
655
|
|
|
656
|
+
// ── Tool: phaedo_sign_mandate (§8.3 signing ceremony) ─────────────────────────
|
|
657
|
+
// Ask the subject to SIGN an AP2 Intent Mandate built from their standing authorizations.
|
|
658
|
+
// Two-call shape like escalation: this deposits the request and returns immediately with a
|
|
659
|
+
// request_id + the mandate; the agent then polls phaedo_sign_status. The subject signs on
|
|
660
|
+
// their phone inside the Secure Enclave (the server never holds the key). Until a signature
|
|
661
|
+
// comes back the mandate is a PROPOSAL, never a credential — do not present it as authorized.
|
|
662
|
+
const SIGN_WINDOW_MS = 5 * 60 * 1000;
|
|
663
|
+
|
|
664
|
+
server.registerTool(
|
|
665
|
+
'phaedo_sign_mandate',
|
|
666
|
+
{
|
|
667
|
+
title: 'Ask the subject to sign a payment authorization (AP2 Intent Mandate)',
|
|
668
|
+
description:
|
|
669
|
+
'Request the subject\'s cryptographic signature on an AP2 Intent Mandate built from their ' +
|
|
670
|
+
'standing authorizations — a credential a merchant or payments network can verify. Returns a ' +
|
|
671
|
+
'request_id and the unsigned mandate immediately; then poll phaedo_sign_status with BOTH, and ' +
|
|
672
|
+
'DO NOT treat the mandate as authorized until it returns signed. The subject signs on their ' +
|
|
673
|
+
'phone (Face ID, key in the Secure Enclave); they may also decline. Use only when you need a ' +
|
|
674
|
+
'verifiable payment credential — not for ordinary consultations.',
|
|
675
|
+
inputSchema: {
|
|
676
|
+
agent_id: z.string().optional(),
|
|
677
|
+
prompt_playback: z.string().optional(), // your stated understanding of the request (AP2)
|
|
678
|
+
},
|
|
679
|
+
// Reaches the subject's device via the relay; read-only w.r.t. local state (no receipt yet).
|
|
680
|
+
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
681
|
+
},
|
|
682
|
+
async (args) => {
|
|
683
|
+
try {
|
|
684
|
+
const vd = await loadConsultVd(BASE_DIR, { preferCache: true });
|
|
685
|
+
const fp = (vd && vd.phaedo_fingerprint) || {};
|
|
686
|
+
const mandate = buildIntentMandatePayload(fp, {
|
|
687
|
+
agentId: args?.agent_id ?? null,
|
|
688
|
+
promptPlayback: args?.prompt_playback ?? null,
|
|
689
|
+
});
|
|
690
|
+
if (!mandate) {
|
|
691
|
+
const body = {
|
|
692
|
+
request_type: 'sign_mandate', status: 'nothing_to_sign',
|
|
693
|
+
message: 'The subject has granted no standing authorizations, so there is nothing to sign. ' +
|
|
694
|
+
'An empty mandate must never be synthesized — it would read as unlimited authority.',
|
|
695
|
+
};
|
|
696
|
+
return { content: [{ type: 'text', text: JSON.stringify(body, null, 2) }], structuredContent: body };
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
// Make sure we hold the subject's signing key BEFORE the sheet goes up.
|
|
700
|
+
//
|
|
701
|
+
// Verification needs the published key. Fetching it lazily at poll time meant a live
|
|
702
|
+
// fingerprint pull fired WHILE the signing sheet was on screen — which puts an
|
|
703
|
+
// authorize card up right as that sheet dismisses, and iOS kills the app when two of
|
|
704
|
+
// these sheets overlap ("already presenting"; the nav layer warns about exactly this).
|
|
705
|
+
// Observed on device: the ceremony completed, then the app crashed and the pull that
|
|
706
|
+
// would have supplied the key died with it, so a genuine signature came back
|
|
707
|
+
// unverifiable. Pulling here is free of that hazard — no sheet exists yet — and it
|
|
708
|
+
// also fails fast, before the subject is asked to sign something we could not check.
|
|
709
|
+
if (!(vd && vd.phaedo_signing_public_key)) {
|
|
710
|
+
try { await loadConsultVd(BASE_DIR); } catch { /* best-effort; poll still retries */ }
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
const pairing = await loadPairingRecord(BASE_DIR);
|
|
714
|
+
const dep = await depositSignRequest(pairing, mandate, { windowMs: SIGN_WINDOW_MS });
|
|
715
|
+
const body = dep.deposited
|
|
716
|
+
? {
|
|
717
|
+
request_type: 'sign_mandate', status: 'pending',
|
|
718
|
+
request_id: dep.request_id, window_ms: dep.window_ms, expires_at: dep.expires_at, woke: dep.woke,
|
|
719
|
+
mandate,
|
|
720
|
+
message: 'Sent to the subject to sign. Poll phaedo_sign_status with this request_id AND this ' +
|
|
721
|
+
'mandate; do not treat it as authorized until it returns signed. If it expires or the ' +
|
|
722
|
+
'subject declines, the mandate stays an unsigned proposal.',
|
|
723
|
+
}
|
|
724
|
+
: {
|
|
725
|
+
request_type: 'sign_mandate', status: 'unreachable', reason: dep.reason,
|
|
726
|
+
message: dep.reason === 'no-relay-device-id' || dep.reason === 'unpaired'
|
|
727
|
+
? 'No real-time channel to the subject (no paired device with a relay lane). Cannot obtain a signature.'
|
|
728
|
+
: 'Could not reach the subject right now. The mandate remains an unsigned proposal.',
|
|
729
|
+
};
|
|
730
|
+
return { content: [{ type: 'text', text: JSON.stringify(body, null, 2) }], structuredContent: body };
|
|
731
|
+
} catch (err) {
|
|
732
|
+
const code = err instanceof PhaedoError ? err.code : 'internal_error';
|
|
733
|
+
return { content: [{ type: 'text', text: `Phaedo sign-mandate error [${code}]: ${err.message}` }], isError: true };
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
);
|
|
737
|
+
|
|
738
|
+
// ── Tool: phaedo_sign_status ──────────────────────────────────────────────────
|
|
739
|
+
// Poll a signing request opened with phaedo_sign_mandate. The `mandate` returned by that
|
|
740
|
+
// call is passed back here to verify against — a tampered mandate simply fails verification
|
|
741
|
+
// (the phone signed only what it displayed), so round-tripping it is safe and restart-proof.
|
|
742
|
+
server.registerTool(
|
|
743
|
+
'phaedo_sign_status',
|
|
744
|
+
{
|
|
745
|
+
title: 'Check whether the subject has signed the mandate',
|
|
746
|
+
description:
|
|
747
|
+
'Poll a request from phaedo_sign_mandate. Pass the request_id AND the mandate that call returned. ' +
|
|
748
|
+
'Returns signed + the mandate carrying the verified signature once the subject signs, refused if ' +
|
|
749
|
+
'they declined, pending while you should keep waiting, or expired once the window lapsed. A signed ' +
|
|
750
|
+
'result is verified against the subject\'s published signing key before it is returned.',
|
|
751
|
+
inputSchema: {
|
|
752
|
+
request_id: z.string(),
|
|
753
|
+
mandate: z.record(z.any()),
|
|
754
|
+
expires_at: z.number().optional(),
|
|
755
|
+
},
|
|
756
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
757
|
+
},
|
|
758
|
+
async (args) => {
|
|
759
|
+
try {
|
|
760
|
+
const vd = await loadConsultVd(BASE_DIR, { preferCache: true });
|
|
761
|
+
let signingPublicKey = vd && vd.phaedo_signing_public_key;
|
|
762
|
+
// NOTE: no live pull here, deliberately. A live pull raises an authorize card on the
|
|
763
|
+
// phone, and this runs while the signing sheet may still be dismissing — overlapping
|
|
764
|
+
// sheets crash iOS ("already presenting"), which is exactly how a good signature was
|
|
765
|
+
// lost on device. The key is fetched at DEPOSIT time instead (see phaedo_sign_mandate),
|
|
766
|
+
// where no sheet exists. If it is still missing here, report that honestly rather than
|
|
767
|
+
// interrupting the subject mid-ceremony to fix it.
|
|
768
|
+
const pairing = await loadPairingRecord(BASE_DIR);
|
|
769
|
+
const r = await pollSignOnce(pairing, args.request_id, args.mandate, {
|
|
770
|
+
expiresAt: args.expires_at, signingPublicKey,
|
|
771
|
+
});
|
|
772
|
+
|
|
773
|
+
// A stale key (the subject rotated their signing identity) would also present as a
|
|
774
|
+
// plain mismatch. That is NOT retried with a live pull here, on purpose: the pull
|
|
775
|
+
// raises an authorize card while the signing sheet may still be dismissing, and
|
|
776
|
+
// overlapping sheets crash iOS. Fetching at DEPOSIT time makes staleness effectively
|
|
777
|
+
// impossible anyway — the key is read moments before the sheet appears, and rotation
|
|
778
|
+
// requires a deliberate device action. A genuine mismatch is reported as such.
|
|
779
|
+
|
|
780
|
+
if (r.resolved && r.signed) {
|
|
781
|
+
// Audit the minted credential — its own log, NOT a §10.6 consult receipt (a
|
|
782
|
+
// signature has no signal and must not feed act-as-me learning). Records a digest
|
|
783
|
+
// of the signed bytes + coarse metadata, never the mandate content or the signature.
|
|
784
|
+
// Best-effort: a failed audit write must never make a real signature look unsigned.
|
|
785
|
+
let recorded = false;
|
|
786
|
+
try {
|
|
787
|
+
await appendSigningAudit(defaultStateDir(), buildSigningAuditRecord({
|
|
788
|
+
requestId: args.request_id, mandate: r.mandate, clientId: CONSULT_OPTS.clientId, agentId: r.mandate?.agent_id,
|
|
789
|
+
}));
|
|
790
|
+
recorded = true;
|
|
791
|
+
} catch (e) {
|
|
792
|
+
process.stderr.write(`[phaedo-mcp] signing-audit write failed (mandate still signed): ${e.message}\n`);
|
|
793
|
+
}
|
|
794
|
+
const body = {
|
|
795
|
+
request_type: 'sign_status', status: 'signed', mandate: r.mandate, recorded,
|
|
796
|
+
message: 'The subject signed. The mandate now carries a verified signature and may be presented ' +
|
|
797
|
+
'as a credential.',
|
|
798
|
+
};
|
|
799
|
+
return { content: [{ type: 'text', text: JSON.stringify(body, null, 2) }], structuredContent: body };
|
|
800
|
+
}
|
|
801
|
+
if (r.resolved && !r.signed) {
|
|
802
|
+
const refused = r.reason === 'refused';
|
|
803
|
+
const body = {
|
|
804
|
+
request_type: 'sign_status', status: refused ? 'refused' : 'not_signed', reason: r.reason,
|
|
805
|
+
...(r.detail ? { detail: r.detail } : {}),
|
|
806
|
+
message: refused
|
|
807
|
+
? 'The subject declined to sign. Do not proceed with an action that needs this credential.'
|
|
808
|
+
: 'A response arrived but did not verify (' + (r.detail || 'unknown') + '). Treat as unsigned; do not proceed.',
|
|
809
|
+
};
|
|
810
|
+
return { content: [{ type: 'text', text: JSON.stringify(body, null, 2) }], structuredContent: body };
|
|
811
|
+
}
|
|
812
|
+
const expired = r.status === 'expired';
|
|
813
|
+
const body = {
|
|
814
|
+
request_type: 'sign_status', status: r.status,
|
|
815
|
+
message: expired
|
|
816
|
+
? 'The signing window lapsed with no signature. The mandate remains an unsigned proposal.'
|
|
817
|
+
: 'Not signed yet. Keep waiting; poll again shortly.',
|
|
818
|
+
};
|
|
819
|
+
return { content: [{ type: 'text', text: JSON.stringify(body, null, 2) }], structuredContent: body };
|
|
820
|
+
} catch (err) {
|
|
821
|
+
const code = err instanceof PhaedoError ? err.code : 'request_malformed';
|
|
822
|
+
return { content: [{ type: 'text', text: `Phaedo sign-status error [${code}]: ${err.message}` }], isError: true };
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
);
|
|
826
|
+
|
|
558
827
|
// ── Resource: phaedo://fingerprint/projection (binding §3.1) ──────────────────
|
|
559
828
|
// Default projection (all layers, standard mode) for resource-only clients.
|
|
560
829
|
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.phaedo-labs/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: inject, consult, escalate, sign.",
|
|
5
|
+
"version": "0.3.1",
|
|
6
6
|
"repository": {
|
|
7
7
|
"url": "https://github.com/phaedo-labs/phaedo-mcp",
|
|
8
8
|
"source": "github",
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
"registryType": "npm",
|
|
14
14
|
"registryBaseUrl": "https://registry.npmjs.org",
|
|
15
15
|
"identifier": "phaedo-mcp",
|
|
16
|
-
"version": "0.
|
|
16
|
+
"version": "0.3.1",
|
|
17
17
|
"transport": {
|
|
18
18
|
"type": "stdio"
|
|
19
19
|
}
|