create-evda 0.1.8 → 0.1.10

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.
Files changed (61) hide show
  1. package/CHANGELOG.md +109 -1
  2. package/README.md +20 -7
  3. package/bin/evda.mjs +277 -7
  4. package/create-evda.mjs +17 -4
  5. package/dist/pairing.mjs +2932 -1684
  6. package/hooks/evda-day-writer-guard.mjs +7 -0
  7. package/lib/acknowledge.mjs +187 -0
  8. package/lib/adopt.mjs +488 -0
  9. package/lib/agents.mjs +1 -1
  10. package/lib/attested-host.mjs +269 -0
  11. package/lib/browse.mjs +354 -0
  12. package/lib/circles.mjs +5 -1
  13. package/lib/clients.mjs +29 -2
  14. package/lib/connect.mjs +9 -2
  15. package/lib/connecting-apps.mjs +56 -0
  16. package/lib/connector-tools.mjs +85 -0
  17. package/lib/corrections.mjs +34 -0
  18. package/lib/credentials-migrate.mjs +120 -0
  19. package/lib/day-file.mjs +173 -15
  20. package/lib/day-writer.mjs +110 -0
  21. package/lib/doctor.mjs +45 -3
  22. package/lib/every-chat.mjs +128 -0
  23. package/lib/filechanges.mjs +203 -68
  24. package/lib/gather-apply.mjs +176 -26
  25. package/lib/gather.mjs +119 -17
  26. package/lib/html-notes.mjs +124 -0
  27. package/lib/keep-hook.mjs +61 -8
  28. package/lib/keychain.mjs +181 -0
  29. package/lib/manifest.mjs +5 -1
  30. package/lib/mcp-auth.mjs +41 -1
  31. package/lib/mcp-http.mjs +42 -4
  32. package/lib/mcp.mjs +1198 -76
  33. package/lib/measurement-log.mjs +249 -0
  34. package/lib/messages.mjs +5 -2
  35. package/lib/migrate-people.mjs +54 -20
  36. package/lib/pairing.mjs +243 -5
  37. package/lib/parity.mjs +47 -13
  38. package/lib/person-folders.mjs +89 -0
  39. package/lib/plugin-hook.mjs +109 -0
  40. package/lib/plugin.mjs +208 -0
  41. package/lib/presets.mjs +26 -5
  42. package/lib/publish.mjs +55 -10
  43. package/lib/qr.mjs +317 -0
  44. package/lib/record.mjs +74 -0
  45. package/lib/routines.mjs +54 -4
  46. package/lib/sigstore-trust.json +20 -0
  47. package/lib/skills.mjs +1 -1
  48. package/lib/sync-lock.mjs +143 -0
  49. package/lib/sync-rules.mjs +154 -1
  50. package/lib/takeout.mjs +169 -0
  51. package/lib/tdx-attestation.mjs +412 -0
  52. package/lib/tdx-trust.json +10 -0
  53. package/lib/tools.mjs +1040 -31
  54. package/lib/touches.mjs +427 -0
  55. package/lib/update-check.mjs +30 -0
  56. package/lib/watch.mjs +160 -0
  57. package/lib/yaml.mjs +8 -4
  58. package/package.json +2 -1
  59. package/skills/evda/SKILL.md +14 -0
  60. package/templates/AGENTS.md +81 -10
  61. package/templates/evda-digest.md +5 -1
@@ -0,0 +1,181 @@
1
+ // macOS Keychain storage for the credential lines gather finds (HQ-1916, builds on HQ-1368).
2
+ // First version, his own words: "it just needs to be super initial versioning" -- the smallest thing
3
+ // that takes the password off disk.
4
+ //
5
+ // At `evda gather apply`, a note gather caught as holding a credential (lib/gather.mjs CRED_LINE, or
6
+ // a bare token line shaped like a secret) is still sealed into Credentials/ as it is today, but each
7
+ // value found in it is written into the login Keychain (service EVÐA) instead of staying in the
8
+ // file. The line keeps its own label; only the value becomes a plain pointer:
9
+ // Password: in your Keychain (EVÐA, wifi.md, password)
10
+ // Not macOS, or the Keychain write fails: the value stays in the file, unchanged, as it did before
11
+ // this ticket, and gather-apply says so in one line. Nothing here calls any MCP tool, and nothing
12
+ // here is registered as one: the owner reads a secret with `evda secret <file>`, on their own Mac,
13
+ // printed to their own terminal only (Spec 07, the sealed folder's own guarantee).
14
+ //
15
+ // EVDA_KEYCHAIN_FALSIFY=<mode,...>, for the tests:
16
+ // no-keychain keychainAvailable() always says no, so a credential file is never redacted
17
+ // write-fails every "security add-generic-password" fails, so the value stays in the file
18
+ //
19
+ // HQ-2051: under node's test runner, nothing here may reach the default (login) keychain. A test run
20
+ // that names no keychain, or names one that does not exist (`security` then falls back to the
21
+ // default instead of failing), throws before `security` runs. On Hestia's CI runner the default
22
+ // keychain does not exist under the throwaway HOME, so such a call raised a "Keychain Not Found"
23
+ // dialog that sat on the screen for hours and failed every Mac journey. A test that truly means the
24
+ // login keychain sets EVDA_KEYCHAIN_ALLOW_DEFAULT=1; none does.
25
+
26
+ import { execFileSync } from "node:child_process";
27
+ import { existsSync } from "node:fs";
28
+ import { CRED_LINE } from "./gather.mjs";
29
+ import { secretShape } from "./references.mjs";
30
+
31
+ export const SERVICE = "EVÐA";
32
+ export const KEYCHAIN_FALSIFY_MODES = ["no-keychain", "write-fails"];
33
+ const falsified = (mode) => (process.env.EVDA_KEYCHAIN_FALSIFY ?? "").split(",").map((s) => s.trim()).includes(mode);
34
+
35
+ // The account a credential's Keychain item is filed under: the record file it came from, and its
36
+ // label, so `evda secret <file>` can find every value a file's pointers name.
37
+ export const accountFor = (fileName, label) => `${fileName} :: ${label}`;
38
+
39
+ // Whether this machine can hold a secret in the Keychain at all. Never throws.
40
+ export function keychainAvailable() {
41
+ if (falsified("no-keychain")) return false;
42
+ if (process.platform !== "darwin") return false;
43
+ try {
44
+ execFileSync("security", ["list-keychains"], { stdio: ["ignore", "ignore", "ignore"] });
45
+ return true;
46
+ } catch {
47
+ return false;
48
+ }
49
+ }
50
+
51
+ // True inside a `node --test` run: its children carry NODE_TEST_CONTEXT, and so does every CLI a
52
+ // test spawns with the inherited env; --test-isolation=none runs in-process with --test in execArgv.
53
+ export function underTestRunner(env = process.env, execArgv = process.execArgv) {
54
+ return Boolean(env.NODE_TEST_CONTEXT) || execArgv.some((a) => a === "--test" || a.startsWith("--test-"));
55
+ }
56
+
57
+ // Throws when a test run would reach the default keychain. Every Keychain call below goes through it.
58
+ export function assertKeychainAllowed(keychainPath, env = process.env) {
59
+ if (!underTestRunner(env) || env.EVDA_KEYCHAIN_ALLOW_DEFAULT === "1") return;
60
+ if (!keychainPath) throw new Error("evda refuses the default keychain under a test run: pass a throwaway keychainPath or set EVDA_KEYCHAIN_FALSIFY (HQ-2051)");
61
+ if (!existsSync(keychainPath)) throw new Error(`evda refuses a keychain that does not exist under a test run (${keychainPath}): \`security\` would fall back to the default keychain (HQ-2051)`);
62
+ }
63
+
64
+ const keychainArgs = (keychainPath) => (keychainPath ? [keychainPath] : []);
65
+
66
+ // `security add-generic-password`, overwriting an item already at this account (a second gather of
67
+ // the same file lands on the same value). Throws on failure; the caller decides what that means.
68
+ export function addSecret({ account, value, keychainPath = null } = {}) {
69
+ if (falsified("write-fails")) throw new Error("EVDA_KEYCHAIN_FALSIFY=write-fails");
70
+ assertKeychainAllowed(keychainPath);
71
+ execFileSync("security", ["add-generic-password", "-a", account, "-s", SERVICE, "-w", value, "-U", ...keychainArgs(keychainPath)], { stdio: ["ignore", "ignore", "pipe"] });
72
+ }
73
+
74
+ // Never throws on a missing item: revert removes what it can and says what it removed. Under a test
75
+ // run it does throw when it would reach the default keychain (HQ-2051).
76
+ export function removeSecret({ account, keychainPath = null } = {}) {
77
+ assertKeychainAllowed(keychainPath);
78
+ try {
79
+ execFileSync("security", ["delete-generic-password", "-a", account, "-s", SERVICE, ...keychainArgs(keychainPath)], { stdio: ["ignore", "ignore", "ignore"] });
80
+ return true;
81
+ } catch {
82
+ return false;
83
+ }
84
+ }
85
+
86
+ // The value at an account, or throws (no item, or the Keychain refused to read it).
87
+ export function readSecret({ account, keychainPath = null } = {}) {
88
+ assertKeychainAllowed(keychainPath);
89
+ return execFileSync("security", ["find-generic-password", "-a", account, "-s", SERVICE, "-w", ...keychainArgs(keychainPath)], { stdio: ["ignore", "pipe", "ignore"] }).toString("utf8").replace(/\n$/, "");
90
+ }
91
+
92
+ // Every credential value on its own line in `text`: a labeled line (CRED_LINE, the same shape that
93
+ // typed the note in the first place) keeps its label and the text before the value, so only the
94
+ // value itself is later replaced; a bare line shaped like a secret (a token with no "label:") has no
95
+ // label of its own, so it is numbered "secret", "secret 2", ... in the order it appears. A labeled
96
+ // line whose value is empty (the code sits on the next line instead) is left alone: multi-line values
97
+ // are out of this first version's scope.
98
+ // A line already holding a pointer is not a value, so a file sealed once has nothing left to move
99
+ // (HQ-2622: the migration's second run is a no-op because of this). Two lines with the same label
100
+ // ("password" twice, or a "secret:" line beside a bare token) get "<label> 2", "<label> 3", ... so
101
+ // each lands on its own Keychain account and neither overwrites the other.
102
+ export function extractSecrets(text) {
103
+ const lines = text.split("\n");
104
+ const out = [];
105
+ let bare = 0;
106
+ for (let i = 0; i < lines.length; i++) {
107
+ const line = lines[i];
108
+ if (POINTER.test(line)) continue;
109
+ const m = CRED_LINE.exec(line);
110
+ if (m) {
111
+ const label = (m[1] ?? m[2]).toLowerCase().replace(/[\s_-]+/g, " ");
112
+ const value = m[3].replace(/[*_\x60]+$/g, "").trim();
113
+ if (!value) continue;
114
+ out.push({ index: i, label, prefix: line.slice(0, line.length - m[3].length), value, whole: false });
115
+ continue;
116
+ }
117
+ if (secretShape(line.trim())) {
118
+ bare += 1;
119
+ out.push({ index: i, label: bare === 1 ? "secret" : `secret ${bare}`, prefix: "", value: line, whole: true });
120
+ }
121
+ }
122
+ const used = new Set();
123
+ for (const s of out) {
124
+ let label = s.label;
125
+ for (let n = 2; used.has(label); n++) label = `${s.label} ${n}`;
126
+ used.add(label);
127
+ s.label = label;
128
+ }
129
+ return out;
130
+ }
131
+
132
+ // A pointer line reads back its account: "in your Keychain (EVÐA, wifi.md, password)".
133
+ const POINTER = /in your Keychain \(([^,]+), ([^,]+), ([^)]+)\)/;
134
+ export const pointerFor = (fileName, label) => `in your Keychain (${SERVICE}, ${fileName}, ${label})`;
135
+
136
+ // Move every credential value in `text` into the Keychain, one item per line, and return the text
137
+ // with each replaced by its pointer. All-or-nothing per file: if any value fails to write, every
138
+ // item this call added is removed again and the original text comes back unchanged, so a file is
139
+ // never left half in the Keychain and half redacted.
140
+ // Returns { ok: true, text, accounts } or { ok: false, why }.
141
+ export function sealForKeychain(text, fileName, { keychainPath = null } = {}) {
142
+ if (!keychainAvailable()) return { ok: false, why: "not on macOS, or the Keychain is unavailable" };
143
+ const secrets = extractSecrets(text);
144
+ if (!secrets.length) return { ok: false, why: "no credential value was found on its own line" };
145
+ const added = [];
146
+ try {
147
+ for (const s of secrets) {
148
+ const account = accountFor(fileName, s.label);
149
+ addSecret({ account, value: s.value, keychainPath });
150
+ added.push(account);
151
+ }
152
+ } catch (e) {
153
+ for (const account of added) removeSecret({ account, keychainPath });
154
+ return { ok: false, why: `the Keychain write failed (${String(e.message ?? e).split("\n")[0]})` };
155
+ }
156
+ const lines = text.split("\n");
157
+ for (const s of secrets) lines[s.index] = s.whole ? pointerFor(fileName, s.label) : `${s.prefix}${pointerFor(fileName, s.label)}`;
158
+ return { ok: true, text: lines.join("\n"), accounts: added };
159
+ }
160
+
161
+ // The owner's own read: every pointer in `text` resolved back to its value, for `evda secret <file>`
162
+ // only. No MCP tool calls this.
163
+ export function resolvePointers(text, { keychainPath = null } = {}) {
164
+ const lines = text.split("\n");
165
+ let resolved = 0;
166
+ let failed = 0;
167
+ for (let i = 0; i < lines.length; i++) {
168
+ const m = POINTER.exec(lines[i]);
169
+ if (!m) continue;
170
+ const account = accountFor(m[2], m[3]);
171
+ try {
172
+ const value = readSecret({ account, keychainPath });
173
+ lines[i] = lines[i].slice(0, m.index) + value + lines[i].slice(m.index + m[0].length);
174
+ resolved += 1;
175
+ } catch {
176
+ lines[i] = `${lines[i]} (could not read this from the Keychain)`;
177
+ failed += 1;
178
+ }
179
+ }
180
+ return { text: lines.join("\n"), resolved, failed };
181
+ }
package/lib/manifest.mjs CHANGED
@@ -16,7 +16,11 @@ export const SCHEMA_VERSION = 1;
16
16
  export const KNOWN_LAYERS = ["record", "ledger", "circles"];
17
17
  // `filing` (HQ-1385) is the owner's answer to the one setup question, "everything" or "asked";
18
18
  // lib/day-file.mjs filingOf reads it for every agent and hook.
19
- export const KNOWN_KEYS = ["evda", "id", "name", "owner", "created", "layers", "mapping", "subrecords", "read_by", "filing"];
19
+ // `every_chat` (HQ-2205) is, per chat app, whether the owner put the every-chat line in that app's
20
+ // settings ("set") or does not want it ("declined"); lib/every-chat.mjs reads it, evda_every_chat writes it.
21
+ // `corrections` (HQ-2325) is the file a correction is appended to, when the owner keeps them in
22
+ // their own file rather than the operating layer's "What I have been taught"; lib/corrections.mjs reads it.
23
+ export const KNOWN_KEYS = ["evda", "id", "name", "owner", "created", "layers", "mapping", "subrecords", "read_by", "filing", "every_chat", "sync", "corrections"];
20
24
 
21
25
  // Whether a record's circles stop at its sub-records (HQ-1371). A sub-record is a directory below
22
26
  // the root holding its own evda.json. "bounded": the parent's circles, coverage, scan and release
package/lib/mcp-auth.mjs CHANGED
@@ -21,6 +21,9 @@
21
21
  // EVDA_MCP_FALSIFY=skip-redirect-check the redirect URI is not compared, at /authorize or /token
22
22
  // EVDA_MCP_FALSIFY=log-content the call log carries the tool arguments (lib/mcp-http.mjs)
23
23
  // EVDA_MCP_FALSIFY=log-args every tool call logs its arguments, stdio and HTTP (lib/mcp.mjs)
24
+ //
25
+ // None of them may be set on a server the internet can reach: a --public-url outside loopback and the
26
+ // reserved test names refuses to start while any switch is set (HQ-2082, refuseFalsifyOnPublicServer).
24
27
 
25
28
  import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
26
29
  import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
@@ -28,7 +31,11 @@ import { homedir } from "node:os";
28
31
  import { join } from "node:path";
29
32
  import { EvdaError } from "./errors.mjs";
30
33
 
31
- export const FALSIFY = (mode, env = process.env) => String(env.EVDA_MCP_FALSIFY ?? "").split(",").map((m) => m.trim()).includes(mode);
34
+ // Every switch, so the startup guard can name the ones that are set rather than ask about each.
35
+ export const MCP_FALSIFY_MODES = ["no-token-check", "accept-expired", "accept-revoked", "skip-pkce", "skip-redirect-check", "log-content", "log-args"];
36
+
37
+ export const falsifyModes = (env = process.env) => String(env.EVDA_MCP_FALSIFY ?? "").split(",").map((m) => m.trim()).filter(Boolean);
38
+ export const FALSIFY = (mode, env = process.env) => falsifyModes(env).includes(mode);
32
39
 
33
40
  export const ACCESS_TTL_MS = 60 * 60 * 1000; // one hour; Claude refreshes on a 401
34
41
  export const REFRESH_TTL_MS = 30 * 24 * 60 * 60 * 1000; // thirty days, renewed on every rotation
@@ -44,6 +51,39 @@ export const SCOPE = "evda";
44
51
  export const HOSTED_REDIRECTS = ["https://claude.ai/api/mcp/auth_callback", "https://claude.com/api/mcp/auth_callback"];
45
52
  const LOOPBACK = new Set(["localhost", "127.0.0.1", "[::1]"]);
46
53
 
54
+ // Can a stranger on the internet reach this address? Loopback is this machine, and the reserved names
55
+ // (RFC 6761 .test .invalid .example .localhost, RFC 2606 example.com and its siblings) resolve nowhere,
56
+ // which is what the tests serve on. Anything else counts as reachable, including an address nobody can
57
+ // parse: a value this cannot read is the one most likely to be a real address typed wrong.
58
+ const RESERVED_TLDS = new Set(["test", "invalid", "example", "localhost"]);
59
+ const RESERVED_NAMES = new Set(["example.com", "example.net", "example.org"]);
60
+ export function isPublicUrl(url) {
61
+ let host;
62
+ try { host = new URL(String(url)).hostname.toLowerCase(); } catch { return true; }
63
+ if (LOOPBACK.has(host) || host === "::1" || /^127\./.test(host)) return false;
64
+ return !(RESERVED_TLDS.has(host.split(".").pop()) || RESERVED_NAMES.has(host));
65
+ }
66
+
67
+ /**
68
+ * HQ-2082: each test hook above turns off one of this server's refusals, and no-token-check turns off
69
+ * authentication altogether, so a switch set on the machine that serves the public address would open
70
+ * the record to anyone who finds it. One can arrive without anybody choosing it: exported from a shell
71
+ * profile, or carried in a run script copied from a test. So the server refuses to start instead.
72
+ *
73
+ * Config time, once, before anything listens. A per-request check would already have served the first
74
+ * request of a process that should never have started, and would leave the switches half working.
75
+ *
76
+ * Returns the switches that are set, which on loopback is where they go on working.
77
+ */
78
+ export function refuseFalsifyOnPublicServer(publicUrl, env = process.env) {
79
+ const set = falsifyModes(env);
80
+ if (!set.length || !isPublicUrl(publicUrl)) return set;
81
+ throw new EvdaError(
82
+ `REFUSED: EVDA_MCP_FALSIFY is set to ${set.join(", ")}, and --public-url ${publicUrl} is an address the internet can reach. Every one of those switches turns off a refusal this server makes, so serving that address with one set would hand the record to anyone who finds it. Unset EVDA_MCP_FALSIFY, or serve on loopback (--public-url http://127.0.0.1:<port>) to use them.`,
83
+ { code: "FALSIFY_ON_PUBLIC_URL", exit: 2 }
84
+ );
85
+ }
86
+
47
87
  export const sha256 = (s) => createHash("sha256").update(s).digest("hex");
48
88
  const b64url = (buf) => buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
49
89
  export const newSecret = (bytes = 32) => b64url(randomBytes(bytes));
package/lib/mcp-http.mjs CHANGED
@@ -7,6 +7,8 @@
7
7
  // It binds to 127.0.0.1 by default and speaks plain HTTP: TLS and the public name belong to the
8
8
  // tunnel in front of it (Tailscale Funnel on Hestia). --public-url is the address the world uses,
9
9
  // and every URL in the metadata is built from it rather than from a Host header anyone can send.
10
+ // When that address is one the internet can reach, no EVDA_MCP_FALSIFY switch may be set: the server
11
+ // refuses to start rather than serve the world with a refusal turned off (HQ-2082).
10
12
  //
11
13
  // The caller is always Claude. A remote client cannot be a model on the owner's own devices, and its
12
14
  // clientInfo is whatever it says, so read_by (HQ-1374) applies as it does to Claude locally.
@@ -21,7 +23,7 @@ import { createServer } from "node:http";
21
23
  import { appendFileSync } from "node:fs";
22
24
  import { randomBytes } from "node:crypto";
23
25
  import { openEvda } from "./manifest.mjs";
24
- import { handle, newSession, TOOLS } from "./mcp.mjs";
26
+ import { handle, newSession, TOOLS, clientInfoOf } from "./mcp.mjs";
25
27
  import { callerFrom } from "./readers.mjs";
26
28
  import * as auth from "./mcp-auth.mjs";
27
29
 
@@ -52,6 +54,23 @@ function limiter() {
52
54
  };
53
55
  }
54
56
 
57
+ // HQ-2330: what a request carries beyond the protocol, by NAME only, so the log can show whether an
58
+ // app sends something that names the conversation (claude.ai reuses one Mcp-Session-Id across chats).
59
+ // Values are never logged: a header or a _meta value can carry anything.
60
+ const PLAIN_HEADERS = new Set(["host", "content-type", "content-length", "authorization", "accept", "accept-encoding", "accept-language", "user-agent", "connection", "origin", "mcp-session-id", "mcp-protocol-version", "last-event-id", "via", "forwarded", "x-real-ip", "cache-control", "pragma", "te", "keep-alive"]);
61
+ const clipName = (k) => String(k).replace(/[^\x21-\x7e]/g, "").slice(0, 64);
62
+ export function extraNames(headers, params) {
63
+ const out = {};
64
+ const h = Object.keys(headers ?? {}).map((k) => k.toLowerCase()).filter((k) => !PLAIN_HEADERS.has(k) && !k.startsWith("x-forwarded-") && !k.startsWith("tailscale-") && !k.startsWith("sec-fetch-")).map(clipName).slice(0, 20);
65
+ if (h.length) out.header_names = h;
66
+ const meta = params?._meta;
67
+ if (meta && typeof meta === "object" && !Array.isArray(meta)) {
68
+ const m = Object.keys(meta).map(clipName).slice(0, 20);
69
+ if (m.length) out.meta_keys = m;
70
+ }
71
+ return out;
72
+ }
73
+
55
74
  const esc = (s) => String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
56
75
  const shortId = (s) => auth.sha256(String(s)).slice(0, 8);
57
76
 
@@ -59,6 +78,7 @@ export function createMcpHttpServer({ dir, version, publicUrl, env = process.env
59
78
  const base = String(publicUrl).replace(/\/+$/, "");
60
79
  const baseUrl = new URL(base);
61
80
  if (baseUrl.protocol !== "https:" && !["127.0.0.1", "localhost"].includes(baseUrl.hostname)) throw new Error(`--public-url must be https (got ${base})`);
81
+ auth.refuseFalsifyOnPublicServer(base, env); // HQ-2082: no test switch on an address the world can reach
62
82
  const resource = `${base}/mcp`;
63
83
  const prmUrl = `${base}/.well-known/oauth-protected-resource/mcp`;
64
84
  const evda = openEvda(dir); // refuse to start on something that is not a record
@@ -75,14 +95,17 @@ export function createMcpHttpServer({ dir, version, publicUrl, env = process.env
75
95
  const url = new URL(req.url, base);
76
96
  const path = url.pathname;
77
97
  const ip = clientIp(req);
78
- const note = { who: null };
98
+ const note = { who: null, method: null, tool: null, reason: null };
79
99
  const send = (status, body, headers = {}) => {
80
100
  const isJson = body !== null && typeof body === "object";
81
101
  res.writeHead(status, { "cache-control": "no-store", "x-content-type-options": "nosniff", ...(isJson ? { "content-type": "application/json" } : {}), ...headers });
82
102
  res.end(body === null || body === undefined ? undefined : isJson ? JSON.stringify(body) : body);
83
103
  // Path only: a query string on /authorize carries a state and a challenge, and on a bad
84
104
  // request could carry anything.
85
- record({ http: req.method, path: knownPath(path), status, ms: clock() - started, ...(note.who ? { conn: note.who } : {}) });
105
+ // A refusal names why (HQ-2235): the error text this server wrote and the JSON-RPC method when
106
+ // the body parsed, never the body itself, so four different 400s can be told apart afterwards.
107
+ const why = status >= 400 && status < 500 ? { reason: note.reason ?? reasonOf(body), ...(note.method ? { method: note.method } : {}), ...(note.tool ? { tool: note.tool } : {}) } : {};
108
+ record({ http: req.method, path: knownPath(path), status, ms: clock() - started, ...(note.who ? { conn: note.who } : {}), ...why });
86
109
  };
87
110
  try {
88
111
  const now = clock();
@@ -143,6 +166,7 @@ export function createMcpHttpServer({ dir, version, publicUrl, env = process.env
143
166
  if (req.method === "DELETE") {
144
167
  const s = sessions.get(sid);
145
168
  if (s && s.family === grant.family) sessions.delete(sid);
169
+ if (!s) note.reason = "session not found";
146
170
  return send(s ? 204 : 404, null);
147
171
  }
148
172
  if (req.method !== "POST") return send(405, { error: "method_not_allowed" }, { allow: "POST, DELETE" });
@@ -151,6 +175,8 @@ export function createMcpHttpServer({ dir, version, publicUrl, env = process.env
151
175
  const raw = await readBody(req, MCP_BODY_LIMIT);
152
176
  try { msg = JSON.parse(raw); } catch { return send(400, { jsonrpc: "2.0", id: null, error: { code: -32700, message: "parse error" } }); }
153
177
  const batch = Array.isArray(msg) ? msg : [msg];
178
+ note.method = batch.map((x) => (typeof x?.method !== "string" ? "none" : METHODS.has(x.method) ? x.method : "unknown_method")).join(",");
179
+ if (batch.length === 1 && batch[0]?.method === "tools/call") note.tool = TOOL_NAMES.has(batch[0].params?.name) ? batch[0].params.name : "unknown_tool";
154
180
  const isInit = batch.some((x) => x?.method === "initialize");
155
181
  let session;
156
182
  let newSid = null;
@@ -158,7 +184,7 @@ export function createMcpHttpServer({ dir, version, publicUrl, env = process.env
158
184
  if (batch.length !== 1) return send(400, { jsonrpc: "2.0", id: null, error: { code: -32600, message: "initialize must be sent alone" } });
159
185
  evictSessions(now);
160
186
  newSid = randomBytes(24).toString("hex");
161
- session = { family: grant.family, last: now, ctx: { version, log: (s) => log(s), env: readerEnv, evda: () => openEvda(dir), session: newSession(), caller: callerFrom({ env: readerEnv }) } };
187
+ session = { family: grant.family, last: now, ctx: { version, log: (s) => log(s), clock, env: readerEnv, evda: () => openEvda(dir), session: newSession(), caller: callerFrom({ env: readerEnv }), keepsToolList: true } };
162
188
  sessions.set(newSid, session);
163
189
  } else {
164
190
  if (!sid) return send(400, { jsonrpc: "2.0", id: null, error: { code: -32600, message: "Mcp-Session-Id header required; initialize first" } });
@@ -177,6 +203,9 @@ export function createMcpHttpServer({ dir, version, publicUrl, env = process.env
177
203
  if (method && one?.id !== undefined && one?.id !== null) {
178
204
  const st = r?.error ? "error" : r?.result?.isError ? "refused" : "ok";
179
205
  const line = { call: method === "tools/call" ? (TOOL_NAMES.has(tool) ? tool : "unknown_tool") : METHODS.has(method) ? method : "unknown_method", status: st, ms: clock() - t0, session: shortId(sid ?? newSid), conn: note.who };
206
+ // Which app connected (HQ-2207): initialize's clientInfo name and version, nothing else of it.
207
+ if (method === "initialize") { const ci = clientInfoOf(one.params); line.client_name = ci.name; line.client_version = ci.version; }
208
+ Object.assign(line, extraNames(req.headers, one.params));
180
209
  if (auth.FALSIFY("log-content", env)) line.args = one.params?.arguments;
181
210
  record(line);
182
211
  }
@@ -268,6 +297,15 @@ function page(title, body) {
268
297
  </head><body><h1>${esc(title)}</h1>${body}</body></html>`;
269
298
  }
270
299
 
300
+ // The error text of a refusal this server wrote: a JSON-RPC error message, an OAuth error_description
301
+ // or error code, or an HTML page's title. Server-authored strings only, capped.
302
+ function reasonOf(body) {
303
+ let r = null;
304
+ if (typeof body === "string") r = /<title>([^<]*)<\/title>/.exec(body)?.[1] ?? null;
305
+ else if (body && typeof body === "object") r = typeof body.error === "object" ? body.error?.message : body.error_description ?? body.error;
306
+ return typeof r === "string" ? r.slice(0, 200) : null;
307
+ }
308
+
271
309
  function withParams(uri, params) {
272
310
  const u = new URL(uri);
273
311
  for (const [k, v] of Object.entries(params)) if (v !== undefined && v !== null) u.searchParams.set(k, v);