phaedo-mcp 0.1.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/LICENSE +202 -0
- package/README.md +301 -0
- package/agreement.js +95 -0
- package/build-package.mjs +22 -0
- package/cache.js +76 -0
- package/consult-core.js +439 -0
- package/consult-policies.sample.json +36 -0
- package/consult-policy.js +136 -0
- package/consult.js +118 -0
- package/errors.js +23 -0
- package/fingerprint-source.js +71 -0
- package/hooks/session-start.mjs +48 -0
- package/install.mjs +368 -0
- package/installers/linux/install-phaedo-mcp.sh +32 -0
- package/installers/macos/Install Phaedo MCP.command +37 -0
- package/installers/windows/Install Phaedo MCP.bat +39 -0
- package/lib/node-vault.js +34 -0
- package/lib/phaedo-crypto.js +36 -0
- package/package.json +49 -0
- package/pair.js +29 -0
- package/pairing.js +184 -0
- package/pairing.sample.json +8 -0
- package/phone-source.js +141 -0
- package/policy-editor.html +146 -0
- package/projection.js +115 -0
- package/pull.js +26 -0
- package/receipts.js +211 -0
- package/sample-fingerprint.json +107 -0
- package/server.js +262 -0
- package/server.json +19 -0
- package/vendor/context-block.js +599 -0
- package/vendor/envelope.js +130 -0
- package/vendor/identity.js +122 -0
- package/vendor/kdf.js +114 -0
- package/vendor/protocol.js +56 -0
package/receipts.js
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
// Phaedo §10.6 — consultation RECEIPTS (build brief M4).
|
|
2
|
+
//
|
|
3
|
+
// An encrypted, append-only, vault-held record of every resolved consultation —
|
|
4
|
+
// the subject's audit trail ("which agent asked what, and what did my oracle
|
|
5
|
+
// say?") and the data layer the delegation gradient (M5 agreement tracking)
|
|
6
|
+
// reads. Receipts NEVER leave the device; Phaedo holds nothing.
|
|
7
|
+
//
|
|
8
|
+
// Crypto: the same AES-256-GCM envelope machinery as everything else (no new
|
|
9
|
+
// crypto). The receipts key is derived from the server's stored root key via
|
|
10
|
+
// HKDF-SHA-256 with the DISTINCT domain-separation context
|
|
11
|
+
// `phaedo:receipts:enc:v0.1` (mirrors §8.4's seed_key pattern), so the receipt
|
|
12
|
+
// store and the fingerprint cache are independent ciphertexts under one root.
|
|
13
|
+
//
|
|
14
|
+
// Semantics (§10.6): APPEND-ONLY. A receipt is never edited after write except
|
|
15
|
+
// its `user_action` field (null at write; later approved|rejected|modified|
|
|
16
|
+
// unknown when override data exists), and never deleted individually — only
|
|
17
|
+
// cap-evicted (oldest first) or destroyed with the account. This module exposes
|
|
18
|
+
// no delete-one API on purpose.
|
|
19
|
+
//
|
|
20
|
+
// Write path: the MCP tool layer calls appendReceipt AFTER resolution — the
|
|
21
|
+
// resolver core stays pure (no I/O). Receipt-write failure must never break the
|
|
22
|
+
// consultation itself (the server warns on stderr and still answers).
|
|
23
|
+
|
|
24
|
+
import { mkdir, readFile, writeFile, chmod } from 'fs/promises';
|
|
25
|
+
import { existsSync } from 'fs';
|
|
26
|
+
import { join } from 'path';
|
|
27
|
+
import { Phaedo, b64uEnc, b64uDec } from './lib/phaedo-crypto.js';
|
|
28
|
+
import { getOrCreateCacheKey, defaultStateDir } from './cache.js';
|
|
29
|
+
|
|
30
|
+
const { Envelope } = Phaedo;
|
|
31
|
+
|
|
32
|
+
export const RECEIPTS_CONTEXT = 'phaedo:receipts:enc:v0.1';
|
|
33
|
+
export const USER_ACTIONS = ['approved', 'rejected', 'modified', 'unknown'];
|
|
34
|
+
export const DEFAULT_CAP = 5000;
|
|
35
|
+
|
|
36
|
+
export function receiptsCapFromEnv(env = process.env) {
|
|
37
|
+
const n = Number(env.PHAEDO_RECEIPTS_CAP);
|
|
38
|
+
return Number.isFinite(n) && n > 0 ? Math.floor(n) : DEFAULT_CAP;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Derive the receipts key from the stored root key under the receipts context.
|
|
42
|
+
// Distinct info string → distinct key: the receipt store is NOT decryptable with
|
|
43
|
+
// the cache key (or vice versa), so a leak of one artifact exposes nothing else.
|
|
44
|
+
async function deriveReceiptsKey(stateDir) {
|
|
45
|
+
const root = await getOrCreateCacheKey(stateDir);
|
|
46
|
+
const ikm = await crypto.subtle.importKey('raw', root, 'HKDF', false, ['deriveBits']);
|
|
47
|
+
const bits = await crypto.subtle.deriveBits(
|
|
48
|
+
{ name: 'HKDF', hash: 'SHA-256', salt: new Uint8Array(0), info: new TextEncoder().encode(RECEIPTS_CONTEXT) },
|
|
49
|
+
ikm,
|
|
50
|
+
32 * 8,
|
|
51
|
+
);
|
|
52
|
+
return new Uint8Array(bits);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const storePath = (stateDir) => join(stateDir, 'receipts.enc');
|
|
56
|
+
|
|
57
|
+
async function readStore(stateDir) {
|
|
58
|
+
const p = storePath(stateDir);
|
|
59
|
+
if (!existsSync(p)) return { receipts_version: '0.1', receipts: [] };
|
|
60
|
+
// Unlike the cache (recoverable from the phone), receipts are the ONLY copy —
|
|
61
|
+
// a corrupt/undecryptable store throws rather than silently starting over.
|
|
62
|
+
const key = await deriveReceiptsKey(stateDir);
|
|
63
|
+
const { envelope } = JSON.parse(await readFile(p, 'utf8'));
|
|
64
|
+
const plaintext = await Envelope.unwrap(envelope, key);
|
|
65
|
+
const store = JSON.parse(new TextDecoder().decode(plaintext));
|
|
66
|
+
plaintext.fill(0);
|
|
67
|
+
return store;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function writeStore(stateDir, store, now) {
|
|
71
|
+
await mkdir(stateDir, { recursive: true, mode: 0o700 });
|
|
72
|
+
const key = await deriveReceiptsKey(stateDir);
|
|
73
|
+
const plaintext = new TextEncoder().encode(JSON.stringify(store));
|
|
74
|
+
const envelope = await Envelope.wrap(plaintext, key, {
|
|
75
|
+
artifact_kind: 'consultation_receipts',
|
|
76
|
+
count: store.receipts.length,
|
|
77
|
+
created_at: new Date(now).toISOString(),
|
|
78
|
+
});
|
|
79
|
+
plaintext.fill(0);
|
|
80
|
+
const p = storePath(stateDir);
|
|
81
|
+
await writeFile(p, JSON.stringify({ envelope }), { mode: 0o600 });
|
|
82
|
+
await chmod(p, 0o600);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Build the §10.6 receipt object from a resolved consultation. Pure. Captures the
|
|
86
|
+
// request descriptor and the response SIGNAL FIELDS only — never layer content,
|
|
87
|
+
// never the projection (§10.4 holds inside the vault too). `via` records which
|
|
88
|
+
// tool resolved it (phaedo_consult | phaedo_check_authorization) so M5 can weigh
|
|
89
|
+
// full consultations and pre-flight matches separately.
|
|
90
|
+
export function buildReceipt({ via, agentId, request, response, now = Date.now() }) {
|
|
91
|
+
const ad = (request && typeof request.action_descriptor === 'object' && request.action_descriptor) || {};
|
|
92
|
+
return {
|
|
93
|
+
receipt_id: crypto.randomUUID(),
|
|
94
|
+
created_at: new Date(now).toISOString(),
|
|
95
|
+
via: via || 'phaedo_consult',
|
|
96
|
+
agent_id: typeof agentId === 'string' ? agentId : null,
|
|
97
|
+
request: {
|
|
98
|
+
consultation_type: request?.consultation_type || 'action_approval',
|
|
99
|
+
action_descriptor: {
|
|
100
|
+
domain: typeof ad.domain === 'string' ? ad.domain : null,
|
|
101
|
+
reversible: typeof ad.reversible === 'boolean' ? ad.reversible : null,
|
|
102
|
+
magnitude: typeof ad.magnitude === 'string' ? ad.magnitude : null,
|
|
103
|
+
amount: typeof ad.amount === 'number' ? ad.amount : null,
|
|
104
|
+
summary: typeof ad.summary === 'string' ? ad.summary.slice(0, 500) : null,
|
|
105
|
+
},
|
|
106
|
+
},
|
|
107
|
+
response: {
|
|
108
|
+
signal: response?.signal ?? null,
|
|
109
|
+
confidence: typeof response?.confidence === 'number' ? response.confidence : null,
|
|
110
|
+
deference_level: response?.deference_level ?? null,
|
|
111
|
+
},
|
|
112
|
+
user_action: null,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Append one receipt; evict oldest beyond the cap. Returns the stored receipt.
|
|
117
|
+
export async function appendReceipt(stateDir, receipt, { cap = DEFAULT_CAP, now = Date.now() } = {}) {
|
|
118
|
+
const store = await readStore(stateDir);
|
|
119
|
+
store.receipts.push(receipt);
|
|
120
|
+
if (store.receipts.length > cap) store.receipts.splice(0, store.receipts.length - cap);
|
|
121
|
+
await writeStore(stateDir, store, now);
|
|
122
|
+
return receipt;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// The ONE permitted mutation (§10.6): set user_action on an existing receipt.
|
|
126
|
+
// Every other field is immutable after write. Returns true if found.
|
|
127
|
+
export async function updateUserAction(stateDir, receiptId, action, { now = Date.now() } = {}) {
|
|
128
|
+
if (!USER_ACTIONS.includes(action)) {
|
|
129
|
+
throw new Error(`user_action must be one of: ${USER_ACTIONS.join(', ')}`);
|
|
130
|
+
}
|
|
131
|
+
const store = await readStore(stateDir);
|
|
132
|
+
const r = store.receipts.find((x) => x.receipt_id === receiptId);
|
|
133
|
+
if (!r) return false;
|
|
134
|
+
r.user_action = action;
|
|
135
|
+
await writeStore(stateDir, store, now);
|
|
136
|
+
return true;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Decrypt and return all receipts (oldest first). The dev/harness read path — and
|
|
140
|
+
// M5's input. Throws on a corrupt store (receipts are the only copy).
|
|
141
|
+
export async function readReceipts(stateDir) {
|
|
142
|
+
return (await readStore(stateDir)).receipts;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Resolve a user-friendly selector to a full receipt_id. Pure. Accepts:
|
|
146
|
+
// - 'latest' (or empty) → the most recent receipt
|
|
147
|
+
// - a full receipt_id, or any UNIQUE id PREFIX (the short id `list` prints)
|
|
148
|
+
// Returns { ok, id } or { ok:false, reason } (none / ambiguous / empty store).
|
|
149
|
+
export function resolveReceiptSelector(receipts, selector) {
|
|
150
|
+
const list = Array.isArray(receipts) ? receipts : [];
|
|
151
|
+
if (!list.length) return { ok: false, reason: 'no receipts yet' };
|
|
152
|
+
if (!selector || selector === 'latest') return { ok: true, id: list[list.length - 1].receipt_id };
|
|
153
|
+
const exact = list.find((r) => r.receipt_id === selector);
|
|
154
|
+
if (exact) return { ok: true, id: exact.receipt_id };
|
|
155
|
+
const hits = list.filter((r) => r.receipt_id.startsWith(selector));
|
|
156
|
+
if (hits.length === 1) return { ok: true, id: hits[0].receipt_id };
|
|
157
|
+
if (hits.length === 0) return { ok: false, reason: `no receipt matches "${selector}"` };
|
|
158
|
+
return { ok: false, reason: `"${selector}" matches ${hits.length} receipts — use more characters` };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Mark a receipt's user_action — what the SUBJECT decided about an action they
|
|
162
|
+
// consulted on (approved | rejected | modified | unknown). This is what makes the
|
|
163
|
+
// agreement metric (agreement.js) live: until receipts carry a user_action, every
|
|
164
|
+
// consultation sits in `awaiting_user_action`. Subject-authored by definition — set
|
|
165
|
+
// it from what YOU did, not from whether an agent proceeded (that would measure
|
|
166
|
+
// agent obedience, not how well the oracle predicts you).
|
|
167
|
+
export async function markOutcome(stateDir, selector, action) {
|
|
168
|
+
if (!USER_ACTIONS.includes(action)) {
|
|
169
|
+
throw new Error(`user_action must be one of: ${USER_ACTIONS.join(', ')}`);
|
|
170
|
+
}
|
|
171
|
+
const sel = resolveReceiptSelector(await readReceipts(stateDir), selector);
|
|
172
|
+
if (!sel.ok) return { matched: false, reason: sel.reason };
|
|
173
|
+
await updateUserAction(stateDir, sel.id, action);
|
|
174
|
+
return { matched: true, receipt_id: sel.id, user_action: action };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// ── Dev CLI: `node receipts.js [list|mark]` — quick on-device readout/marking ──
|
|
178
|
+
// All local: decrypts with the local key, nothing leaves this machine.
|
|
179
|
+
// node receipts.js list # list (shows a short id per line)
|
|
180
|
+
// node receipts.js mark <id|prefix|latest> <approved|rejected|modified|unknown>
|
|
181
|
+
if (process.argv[1] && process.argv[1].endsWith('receipts.js')) {
|
|
182
|
+
const [cmd, a, b] = process.argv.slice(2);
|
|
183
|
+
const dir = defaultStateDir();
|
|
184
|
+
const run = async () => {
|
|
185
|
+
if (cmd === 'mark') {
|
|
186
|
+
if (!a || !b) { console.error('usage: node receipts.js mark <id|prefix|latest> <approved|rejected|modified|unknown>'); process.exit(2); }
|
|
187
|
+
const res = await markOutcome(dir, a, b);
|
|
188
|
+
if (!res.matched) { console.error(`mark: ${res.reason}`); process.exit(1); }
|
|
189
|
+
console.log(`marked ${res.receipt_id.slice(0, 8)} → user_action: ${res.user_action}`);
|
|
190
|
+
console.log('(run `npm run agreement` to see it counted)');
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
if (cmd === 'list' || cmd === undefined) {
|
|
194
|
+
const rs = await readReceipts(dir);
|
|
195
|
+
console.log(`${rs.length} receipt(s) in ${dir}/receipts.enc`);
|
|
196
|
+
console.log(`(mark one: node receipts.js mark <id> approved|rejected|modified)\n`);
|
|
197
|
+
for (const r of rs.slice(-50)) {
|
|
198
|
+
const ad = r.request.action_descriptor;
|
|
199
|
+
console.log(
|
|
200
|
+
`${r.receipt_id.slice(0, 8)} ${r.created_at} ${r.via === 'phaedo_check_authorization' ? '[pre-flight]' : '[consult] '}` +
|
|
201
|
+
` ${r.request.consultation_type} ${ad.domain || '-'}` +
|
|
202
|
+
` → ${r.response.signal} (conf ${r.response.confidence}, def ${r.response.deference_level})` +
|
|
203
|
+
` user_action: ${r.user_action ?? '—'}`
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
console.error(`unknown command "${cmd}" — use: list | mark`); process.exit(2);
|
|
209
|
+
};
|
|
210
|
+
run().catch((e) => { console.error(`receipts: ${e.message}`); process.exit(1); });
|
|
211
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
{
|
|
2
|
+
"phaedo_fingerprint": {
|
|
3
|
+
"fingerprint_id": "sample-0000-0000-0000-000000000000",
|
|
4
|
+
"updated_at": "2026-06-01T12:00:00.000Z",
|
|
5
|
+
"persona_strength": 0.58,
|
|
6
|
+
"layers": {
|
|
7
|
+
"surface": {
|
|
8
|
+
"label": "Communication style",
|
|
9
|
+
"responses": [
|
|
10
|
+
{
|
|
11
|
+
"label": "Response Length",
|
|
12
|
+
"question": "How long should answers be?",
|
|
13
|
+
"answer": "Thorough — go deep and cover edge cases. Length is fine when it earns its place."
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
"label": "Formatting",
|
|
17
|
+
"question": "Preferred formatting?",
|
|
18
|
+
"answer": "Inline examples first, then plain prose, then bullet lists. Numbered steps for procedures."
|
|
19
|
+
}
|
|
20
|
+
],
|
|
21
|
+
"summary": "Prefers concise, direct answers; bottom-line first.",
|
|
22
|
+
"signals": [
|
|
23
|
+
{
|
|
24
|
+
"field": "response_length_pref",
|
|
25
|
+
"polarity": "positive",
|
|
26
|
+
"value": "thorough",
|
|
27
|
+
"confidence": 0.7,
|
|
28
|
+
"observation_count": 1,
|
|
29
|
+
"source": "questionnaire"
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
"field": "directness",
|
|
33
|
+
"polarity": "positive",
|
|
34
|
+
"value": "high",
|
|
35
|
+
"confidence": 0.62,
|
|
36
|
+
"observation_count": 3,
|
|
37
|
+
"source": "delta"
|
|
38
|
+
}
|
|
39
|
+
]
|
|
40
|
+
},
|
|
41
|
+
"domain_and_expertise": {
|
|
42
|
+
"label": "Domain and expertise",
|
|
43
|
+
"responses": [
|
|
44
|
+
{
|
|
45
|
+
"label": "Primary Expertise",
|
|
46
|
+
"question": "What is your domain?",
|
|
47
|
+
"answer": "Operational frameworks, policy, and process for HR teams. Uses Python and SQL occasionally to wrangle data."
|
|
48
|
+
}
|
|
49
|
+
],
|
|
50
|
+
"signals": [
|
|
51
|
+
{
|
|
52
|
+
"field": "expertise_domain",
|
|
53
|
+
"polarity": "positive",
|
|
54
|
+
"value": "operations",
|
|
55
|
+
"confidence": 0.7,
|
|
56
|
+
"observation_count": 1,
|
|
57
|
+
"source": "questionnaire"
|
|
58
|
+
}
|
|
59
|
+
]
|
|
60
|
+
},
|
|
61
|
+
"decision_and_risk": {
|
|
62
|
+
"label": "Decision and risk",
|
|
63
|
+
"responses": [
|
|
64
|
+
{
|
|
65
|
+
"label": "Decision Threshold",
|
|
66
|
+
"question": "When do you commit?",
|
|
67
|
+
"answer": "Make sure I have covered the main risks before committing."
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
"label": "Value Tradeoffs",
|
|
71
|
+
"question": "What do you trade off?",
|
|
72
|
+
"answer": "Accuracy over speed; Thoroughness over simplicity."
|
|
73
|
+
}
|
|
74
|
+
]
|
|
75
|
+
},
|
|
76
|
+
"behavioral": {
|
|
77
|
+
"label": "Working style",
|
|
78
|
+
"responses": [
|
|
79
|
+
{
|
|
80
|
+
"label": "Handling Ambiguity",
|
|
81
|
+
"question": "When a request is ambiguous?",
|
|
82
|
+
"answer": "Ask a clarifying question before doing anything."
|
|
83
|
+
}
|
|
84
|
+
]
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
},
|
|
88
|
+
"phaedo_behavioral_signals": {
|
|
89
|
+
"surface": {
|
|
90
|
+
"too_long": 3,
|
|
91
|
+
"more_direct": 2
|
|
92
|
+
}
|
|
93
|
+
},
|
|
94
|
+
"phaedo_linguistic_profile": {
|
|
95
|
+
"messageCount": 64,
|
|
96
|
+
"avgWordCount": 28,
|
|
97
|
+
"imperativeRate": 0.18,
|
|
98
|
+
"platformDistribution": {
|
|
99
|
+
"Claude": 9,
|
|
100
|
+
"ChatGPT": 3
|
|
101
|
+
}
|
|
102
|
+
},
|
|
103
|
+
"phaedo_session_metrics": {
|
|
104
|
+
"sessionCount": 6,
|
|
105
|
+
"avgMessagesPerSession": 8
|
|
106
|
+
}
|
|
107
|
+
}
|
package/server.js
ADDED
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Phaedo MCP server — Phase 1 (push injection).
|
|
3
|
+
//
|
|
4
|
+
// Exposes the fingerprint projection to any MCP client (Claude Desktop, Claude
|
|
5
|
+
// Code, Cursor, agent frameworks) per docs/protocol/mcp-binding.md:
|
|
6
|
+
// - tool phaedo_request_injection (§9.2 request → §9.3 response)
|
|
7
|
+
// - resource phaedo://fingerprint/projection (default projection, all layers)
|
|
8
|
+
//
|
|
9
|
+
// Injection-only: MCP does not return the conversation, so there is NO capture
|
|
10
|
+
// here (binding §5). Local stdio transport only in this cut.
|
|
11
|
+
|
|
12
|
+
import { fileURLToPath } from 'url';
|
|
13
|
+
import { dirname, join } from 'path';
|
|
14
|
+
import { readFileSync, existsSync } from 'fs';
|
|
15
|
+
import { z } from 'zod';
|
|
16
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
17
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
18
|
+
|
|
19
|
+
import { loadFingerprint } from './fingerprint-source.js';
|
|
20
|
+
import { buildInjectionResponse, errorResponse, PhaedoError } from './projection.js';
|
|
21
|
+
import { resolveConsultation, checkAuthorization, consultOptsFromEnv, CONSULTATION_TYPES } from './consult.js';
|
|
22
|
+
import { defaultStateDir } from './cache.js';
|
|
23
|
+
import { buildReceipt, appendReceipt, receiptsCapFromEnv } from './receipts.js';
|
|
24
|
+
|
|
25
|
+
const BASE_DIR = dirname(fileURLToPath(import.meta.url));
|
|
26
|
+
|
|
27
|
+
// §10.6 receipts: every resolved consultation gets an encrypted, append-only,
|
|
28
|
+
// on-device receipt. Written AFTER resolution (the resolver stays pure) and
|
|
29
|
+
// FAIL-SAFE: a receipt-write failure warns on stderr but never breaks the answer.
|
|
30
|
+
const RECEIPTS_CAP = receiptsCapFromEnv(process.env);
|
|
31
|
+
async function emitReceipt(via, args, response) {
|
|
32
|
+
try {
|
|
33
|
+
const receipt = buildReceipt({ via, agentId: args?.agent_id, request: args, response });
|
|
34
|
+
await appendReceipt(defaultStateDir(), receipt, { cap: RECEIPTS_CAP });
|
|
35
|
+
} catch (e) {
|
|
36
|
+
process.stderr.write(`[phaedo-mcp] receipt write failed (consultation still answered): ${e.message}\n`);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const CONSULT_OPTS = consultOptsFromEnv(process.env);
|
|
41
|
+
// Deference policies: PHAEDO_CONSULT_POLICIES if set, else the default file beside
|
|
42
|
+
// the server (mcp/consult-policies.json — what policy-editor.html produces). Zero
|
|
43
|
+
// config: drop the file in and restart the client. (Portable vault policies in
|
|
44
|
+
// phaedo_consult_policies always apply too, regardless of this file.)
|
|
45
|
+
const POLICIES_PATH = process.env.PHAEDO_CONSULT_POLICIES || join(BASE_DIR, 'consult-policies.json');
|
|
46
|
+
if (existsSync(POLICIES_PATH)) {
|
|
47
|
+
try { CONSULT_OPTS.policies = JSON.parse(readFileSync(POLICIES_PATH, 'utf8')); }
|
|
48
|
+
catch (e) { process.stderr.write(`[phaedo-mcp] ignoring malformed consult policies (${POLICIES_PATH}): ${e.message}\n`); }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const server = new McpServer({ name: 'phaedo', version: '0.1.0' });
|
|
52
|
+
|
|
53
|
+
// ── Tool: phaedo_request_injection (binding §3.1) ─────────────────────────────
|
|
54
|
+
server.registerTool(
|
|
55
|
+
'phaedo_request_injection',
|
|
56
|
+
{
|
|
57
|
+
title: 'Request a Phaedo fingerprint injection',
|
|
58
|
+
description:
|
|
59
|
+
"Returns the user's Phaedo cognitive-fingerprint projection as a system-prompt " +
|
|
60
|
+
'block to insert at the start of an AI session, so the assistant responds in the ' +
|
|
61
|
+
"user's style. Call once at session start and prepend the returned `projection`.",
|
|
62
|
+
inputSchema: {
|
|
63
|
+
session_context: z
|
|
64
|
+
.object({
|
|
65
|
+
platform: z.string().optional(),
|
|
66
|
+
task_hint: z.string().optional(),
|
|
67
|
+
audience_hint: z.string().optional(),
|
|
68
|
+
max_tokens: z.number().optional(),
|
|
69
|
+
})
|
|
70
|
+
.optional(),
|
|
71
|
+
mode: z.enum(['standard', 'enhanced_privacy']).optional(),
|
|
72
|
+
requested_layers: z.array(z.string()).optional(),
|
|
73
|
+
consumer_id: z.string().optional(),
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
async (args) => {
|
|
77
|
+
try {
|
|
78
|
+
const vd = await loadFingerprint(BASE_DIR);
|
|
79
|
+
const response = buildInjectionResponse(vd, args || {});
|
|
80
|
+
return {
|
|
81
|
+
content: [{ type: 'text', text: JSON.stringify(response, null, 2) }],
|
|
82
|
+
structuredContent: response,
|
|
83
|
+
};
|
|
84
|
+
} catch (err) {
|
|
85
|
+
// Surface as an MCP tool error carrying the §9.3 error response, with the
|
|
86
|
+
// §9.5 code preserved (binding §4).
|
|
87
|
+
const body = errorResponse(err);
|
|
88
|
+
const code = err instanceof PhaedoError ? err.code : 'internal_error';
|
|
89
|
+
return {
|
|
90
|
+
content: [{ type: 'text', text: `Phaedo injection error [${code}]: ${JSON.stringify(body)}` }],
|
|
91
|
+
isError: true,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
// ── Tool: phaedo_consult (binding §3.2, spec §10) ─────────────────────────────
|
|
98
|
+
// Agent consultation: query the fingerprint at a decision point and get back a
|
|
99
|
+
// constrained SIGNAL (§10.3) — never layer content (§10.4 boundary). One tool,
|
|
100
|
+
// all four consultation types via `consultation_type`.
|
|
101
|
+
server.registerTool(
|
|
102
|
+
'phaedo_consult',
|
|
103
|
+
{
|
|
104
|
+
title: 'Consult the Phaedo fingerprint at a decision point',
|
|
105
|
+
description:
|
|
106
|
+
"Ask how the user's cognitive pattern bears on a decision — without ingesting the fingerprint. " +
|
|
107
|
+
'Returns a signal (proceed | proceed_with_note | clarify | escalate | decline), a confidence, a ' +
|
|
108
|
+
'short rationale_hint, and a deference_level (how strongly to weight it). Use at agent decision points.',
|
|
109
|
+
inputSchema: {
|
|
110
|
+
consultation_type: z.enum(CONSULTATION_TYPES),
|
|
111
|
+
agent_id: z.string().optional(),
|
|
112
|
+
action_descriptor: z
|
|
113
|
+
.object({
|
|
114
|
+
domain: z.string().optional(),
|
|
115
|
+
reversible: z.boolean().optional(),
|
|
116
|
+
magnitude: z.enum(['low', 'medium', 'high']).optional(),
|
|
117
|
+
amount: z.number().optional(),
|
|
118
|
+
summary: z.string().optional(),
|
|
119
|
+
})
|
|
120
|
+
.optional(),
|
|
121
|
+
context: z.record(z.any()).optional(),
|
|
122
|
+
},
|
|
123
|
+
},
|
|
124
|
+
async (args) => {
|
|
125
|
+
try {
|
|
126
|
+
const vd = await loadFingerprint(BASE_DIR);
|
|
127
|
+
const response = await resolveConsultation(vd, args || {}, CONSULT_OPTS);
|
|
128
|
+
await emitReceipt('phaedo_consult', args, response); // every resolution, abstains included
|
|
129
|
+
return {
|
|
130
|
+
content: [{ type: 'text', text: JSON.stringify(response, null, 2) }],
|
|
131
|
+
structuredContent: response,
|
|
132
|
+
};
|
|
133
|
+
} catch (err) {
|
|
134
|
+
const body = errorResponse(err, 'consultation');
|
|
135
|
+
const code = err instanceof PhaedoError ? err.code : 'internal_error';
|
|
136
|
+
return {
|
|
137
|
+
content: [{ type: 'text', text: `Phaedo consultation error [${code}]: ${JSON.stringify(body)}` }],
|
|
138
|
+
isError: true,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
// ── Tool: phaedo_check_authorization (binding §3.2, spec §10.5) ───────────────
|
|
145
|
+
// Lightweight, DETERMINISTIC-only pre-flight: does a standing authorization
|
|
146
|
+
// (§10.5 force rule) already decide this action? Skips inferred evaluation and the
|
|
147
|
+
// model judge entirely — a fast check an agent runs before a full phaedo_consult.
|
|
148
|
+
// Returns { authorization_matched, signal, rule_id, rationale_hint }; a match is the
|
|
149
|
+
// subject's pre-decision (confidence 1.0 / deference high, by definition). Never
|
|
150
|
+
// returns layer content (§10.4).
|
|
151
|
+
server.registerTool(
|
|
152
|
+
'phaedo_check_authorization',
|
|
153
|
+
{
|
|
154
|
+
title: 'Check standing authorizations for an action',
|
|
155
|
+
description:
|
|
156
|
+
"Deterministic pre-flight: check whether one of the user's standing authorizations " +
|
|
157
|
+
'(§10.5) already decides this action — escalate, decline, or pre-approve — without running ' +
|
|
158
|
+
'the full consultation. Returns authorization_matched plus the authorized signal and the ' +
|
|
159
|
+
'rule that fired. If no rule matches, fall back to phaedo_consult or your own default.',
|
|
160
|
+
inputSchema: {
|
|
161
|
+
consultation_type: z.enum(CONSULTATION_TYPES).optional(),
|
|
162
|
+
agent_id: z.string().optional(),
|
|
163
|
+
action_descriptor: z
|
|
164
|
+
.object({
|
|
165
|
+
domain: z.string().optional(),
|
|
166
|
+
reversible: z.boolean().optional(),
|
|
167
|
+
magnitude: z.enum(['low', 'medium', 'high']).optional(),
|
|
168
|
+
amount: z.number().optional(),
|
|
169
|
+
summary: z.string().optional(),
|
|
170
|
+
})
|
|
171
|
+
.optional(),
|
|
172
|
+
},
|
|
173
|
+
},
|
|
174
|
+
async (args) => {
|
|
175
|
+
try {
|
|
176
|
+
const req = { consultation_type: 'action_approval', ...(args || {}) };
|
|
177
|
+
const vd = await loadFingerprint(BASE_DIR);
|
|
178
|
+
const response = checkAuthorization(vd, req, CONSULT_OPTS);
|
|
179
|
+
// A matched pre-flight IS a resolved decision (the authorization fired) →
|
|
180
|
+
// receipt. A no-match is not a decision — no receipt (keeps the store from
|
|
181
|
+
// filling with probes; the follow-up phaedo_consult writes its own).
|
|
182
|
+
if (response.authorization_matched) {
|
|
183
|
+
await emitReceipt('phaedo_check_authorization', req, { signal: response.signal, confidence: 1.0, deference_level: 'high' });
|
|
184
|
+
}
|
|
185
|
+
return {
|
|
186
|
+
content: [{ type: 'text', text: JSON.stringify(response, null, 2) }],
|
|
187
|
+
structuredContent: response,
|
|
188
|
+
};
|
|
189
|
+
} catch (err) {
|
|
190
|
+
const body = errorResponse(err, 'authorization_check');
|
|
191
|
+
const code = err instanceof PhaedoError ? err.code : 'internal_error';
|
|
192
|
+
return {
|
|
193
|
+
content: [{ type: 'text', text: `Phaedo authorization-check error [${code}]: ${JSON.stringify(body)}` }],
|
|
194
|
+
isError: true,
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
);
|
|
199
|
+
|
|
200
|
+
// ── Resource: phaedo://fingerprint/projection (binding §3.1) ──────────────────
|
|
201
|
+
// Default projection (all layers, standard mode) for resource-only clients.
|
|
202
|
+
server.registerResource(
|
|
203
|
+
'phaedo-projection',
|
|
204
|
+
'phaedo://fingerprint/projection',
|
|
205
|
+
{
|
|
206
|
+
title: 'Phaedo fingerprint projection',
|
|
207
|
+
description: 'Default Phaedo injection projection (all layers, standard mode).',
|
|
208
|
+
mimeType: 'text/plain',
|
|
209
|
+
},
|
|
210
|
+
async (uri) => {
|
|
211
|
+
const vd = await loadFingerprint(BASE_DIR);
|
|
212
|
+
const response = buildInjectionResponse(vd, { requested_layers: ['all'], mode: 'standard' });
|
|
213
|
+
return { contents: [{ uri: uri.href, mimeType: 'text/plain', text: response.projection }] };
|
|
214
|
+
}
|
|
215
|
+
);
|
|
216
|
+
|
|
217
|
+
// ── Prompt: phaedo (one-click session-start injection) ────────────────────────
|
|
218
|
+
// MCP prompts surface in clients as a user-invokable command (a `/phaedo` slash
|
|
219
|
+
// command in Claude Desktop / Claude Code). Selecting it inserts the projection
|
|
220
|
+
// as a conversation message, so the user gets near-automatic injection without
|
|
221
|
+
// having to ask the model to call the tool. The text is rendered by the SAME
|
|
222
|
+
// context-block path as the tool/resource — byte-identical injection.
|
|
223
|
+
server.registerPrompt(
|
|
224
|
+
'phaedo',
|
|
225
|
+
{
|
|
226
|
+
title: 'Load my Phaedo fingerprint',
|
|
227
|
+
description:
|
|
228
|
+
"Insert your Phaedo cognitive-fingerprint projection so the assistant responds in your " +
|
|
229
|
+
"style. Run once at the start of a session.",
|
|
230
|
+
},
|
|
231
|
+
async () => {
|
|
232
|
+
const vd = await loadFingerprint(BASE_DIR);
|
|
233
|
+
const response = buildInjectionResponse(vd, { requested_layers: ['all'], mode: 'standard' });
|
|
234
|
+
return {
|
|
235
|
+
messages: [
|
|
236
|
+
{
|
|
237
|
+
role: 'user',
|
|
238
|
+
content: {
|
|
239
|
+
type: 'text',
|
|
240
|
+
text:
|
|
241
|
+
'The following is my Phaedo cognitive fingerprint — a profile of how I think, ' +
|
|
242
|
+
'communicate, and want responses formatted. Treat it as authoritative guidance for ' +
|
|
243
|
+
'the rest of this conversation, and do not announce that you read it.\n\n' +
|
|
244
|
+
response.projection,
|
|
245
|
+
},
|
|
246
|
+
},
|
|
247
|
+
],
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
);
|
|
251
|
+
|
|
252
|
+
async function main() {
|
|
253
|
+
const transport = new StdioServerTransport();
|
|
254
|
+
await server.connect(transport);
|
|
255
|
+
// stdio transport owns stdout for protocol framing — never console.log here.
|
|
256
|
+
process.stderr.write('[phaedo-mcp] ready on stdio\n');
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
main().catch((err) => {
|
|
260
|
+
process.stderr.write(`[phaedo-mcp] fatal: ${err.stack || err}\n`);
|
|
261
|
+
process.exit(1);
|
|
262
|
+
});
|
package/server.json
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
|
|
3
|
+
"name": "io.github.galavoxx/phaedo",
|
|
4
|
+
"description": "Carry your Phaedo cognitive fingerprint into any MCP client: inject your style at session start, and consult your decision pattern (proceed/clarify/escalate/decline) at agent decision points. Local, user-owned, privacy-preserving.",
|
|
5
|
+
"version": "0.1.0",
|
|
6
|
+
"repository": {
|
|
7
|
+
"url": "https://github.com/galavoxx/phaedo",
|
|
8
|
+
"source": "github"
|
|
9
|
+
},
|
|
10
|
+
"packages": [
|
|
11
|
+
{
|
|
12
|
+
"registryType": "npm",
|
|
13
|
+
"registryBaseUrl": "https://registry.npmjs.org",
|
|
14
|
+
"identifier": "phaedo-mcp",
|
|
15
|
+
"version": "0.1.0",
|
|
16
|
+
"transport": { "type": "stdio" }
|
|
17
|
+
}
|
|
18
|
+
]
|
|
19
|
+
}
|