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/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
+ }