@openparachute/vault 0.4.3 → 0.4.4-rc.11

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.
@@ -1,36 +1,133 @@
1
1
  /**
2
- * URL picker for `parachute-vault mcp-install`. The URL written into
3
- * `~/.claude.json` must match vault's advertised OAuth issuer for the origin
4
- * the client will reach the server on — otherwise strict clients (Claude
5
- * Code's MCP SDK) reject discovery on origin/issuer mismatch (RFC 8414 §3.1).
2
+ * Helpers for `parachute-vault mcp-install`. Three concerns live here:
6
3
  *
7
- * Selection order:
8
- * 1. `PARACHUTE_HUB_ORIGIN` env (vault is advertising the hub as issuer).
9
- * 2. `~/.parachute/expose-state.json` canonical FQDN (active tailnet /
10
- * public exposure the CLI brought up).
11
- * 3. Loopback on the configured port.
4
+ * 1. **URL pickers.** The MCP URL written into the client config must match
5
+ * vault's advertised OAuth issuer for the origin the client will reach
6
+ * the server on otherwise strict clients (Claude Code's MCP SDK)
7
+ * reject discovery on origin/issuer mismatch (RFC 8414 §3.1). Two
8
+ * pickers: `chooseMcpUrl` returns the full `<origin>/vault/<name>/mcp`
9
+ * shape for the entry; `chooseHubOrigin` returns the bare `<origin>` for
10
+ * the hub-mint API call.
11
+ *
12
+ * 2. **Operator-token reader.** Reads `~/.parachute/operator.token` (or
13
+ * `$PARACHUTE_HOME/operator.token`). The hub-mint path uses it as the
14
+ * bearer for `POST <hub>/api/auth/mint-token`. Returns null when absent
15
+ * or empty — caller decides whether that's a hard error.
16
+ *
17
+ * 3. **Hub mint-token client.** `mintHubJwt` posts to
18
+ * `<hub>/api/auth/mint-token` with the operator bearer and returns the
19
+ * scope-narrow JWT. Test seam for `fetch` injected as an opt so unit
20
+ * tests don't need a real hub.
21
+ *
22
+ * 4. **Install target resolver.** `resolveInstallTarget` picks between
23
+ * `~/.claude.json` (user) and `./.mcp.json` (project) based on the
24
+ * `--install-scope` flag.
12
25
  */
13
26
 
14
- import { existsSync, readFileSync } from "node:fs";
27
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
15
28
  import { homedir } from "node:os";
16
29
  import { resolve } from "node:path";
17
30
 
31
+ /**
32
+ * Strip every vault MCP entry from ~/.claude.json (user-scope + every
33
+ * local-scope `projects[*].mcpServers`) and ./.mcp.json (project-scope at
34
+ * cwd). Cleanup walks every `projects[*]` slot so an operator who installed
35
+ * locally in one directory can uninstall from anywhere without remembering
36
+ * where. Silent no-op on missing files / malformed JSON.
37
+ *
38
+ * Lives in mcp-install.ts (not cli.ts) so tests can call it directly
39
+ * without triggering cli.ts's top-level dispatch on import.
40
+ */
41
+ export function removeMcpConfig(): void {
42
+ // Prefer `process.env.HOME` over cached `homedir()` — Bun caches the OS
43
+ // userinfo at process start so in-process HOME overrides (tests, exotic
44
+ // chrooting) don't apply via homedir(). Matches resolveInstallTarget's
45
+ // home-resolution pattern.
46
+ const home = process.env.HOME ?? homedir();
47
+ const claudeJsonPath = resolve(home, ".claude.json");
48
+ const projectMcpJsonPath = resolve(process.cwd(), ".mcp.json");
49
+ for (const path of [claudeJsonPath, projectMcpJsonPath]) {
50
+ if (!existsSync(path)) continue;
51
+ try {
52
+ const config = JSON.parse(readFileSync(path, "utf-8"));
53
+ // Drop the singular key and every per-vault `parachute-vault-<name>`
54
+ // entry. Legacy `parachute-vault/<name>` (slash-form) sub-keys from a
55
+ // pre-multi-vault pattern still get cleaned up here.
56
+ const stripVaultKeys = (servers: Record<string, unknown> | undefined) => {
57
+ if (!servers) return;
58
+ for (const key of Object.keys(servers)) {
59
+ if (
60
+ key === "parachute-vault" ||
61
+ key.startsWith("parachute-vault-") ||
62
+ key.startsWith("parachute-vault/")
63
+ ) {
64
+ delete servers[key];
65
+ }
66
+ }
67
+ };
68
+ stripVaultKeys(config.mcpServers);
69
+ // Local-scope cleanup: walk every project entry and strip vault keys.
70
+ if (config.projects && typeof config.projects === "object") {
71
+ for (const projectKey of Object.keys(config.projects)) {
72
+ const projectEntry = config.projects[projectKey];
73
+ if (projectEntry && typeof projectEntry === "object") {
74
+ stripVaultKeys(projectEntry.mcpServers);
75
+ }
76
+ }
77
+ }
78
+ writeFileSync(path, JSON.stringify(config, null, 2) + "\n");
79
+ } catch {}
80
+ }
81
+ }
82
+
83
+ // ---------------------------------------------------------------------------
84
+ // URL picking
85
+ // ---------------------------------------------------------------------------
86
+
18
87
  export type McpUrlSource = "hub-origin" | "expose-state" | "loopback";
19
88
 
89
+ /**
90
+ * Pick the URL written into the MCP client's `mcpServers.<key>.url` slot.
91
+ * Returns the per-vault MCP endpoint (`/vault/<name>/mcp`); see
92
+ * `chooseHubOrigin` for the bare-origin form used by hub API calls.
93
+ *
94
+ * Source order:
95
+ * 1. `PARACHUTE_HUB_ORIGIN` env (vault is advertising the hub as issuer).
96
+ * 2. `~/.parachute/expose-state.json` canonical FQDN (active tailnet /
97
+ * public exposure the CLI brought up).
98
+ * 3. Loopback on the configured port.
99
+ */
20
100
  export function chooseMcpUrl(
21
101
  vaultName: string,
22
102
  port: number,
23
103
  env: { PARACHUTE_HUB_ORIGIN?: string | undefined } = process.env as { PARACHUTE_HUB_ORIGIN?: string },
104
+ ): { url: string; source: McpUrlSource } {
105
+ const origin = chooseHubOrigin(port, env);
106
+ return { url: `${origin.url}/vault/${vaultName}/mcp`, source: origin.source };
107
+ }
108
+
109
+ /**
110
+ * Pick the bare hub origin (no path suffix). Used by hub-mint when posting
111
+ * to `<origin>/api/auth/mint-token`. Same source order as `chooseMcpUrl`.
112
+ *
113
+ * Note: when the source is `loopback`, the origin is *vault's* loopback URL,
114
+ * not a hub. Hub-mint against a loopback origin will fail at the network
115
+ * layer (no hub on that port) — the caller catches and surfaces a clear
116
+ * "no hub configured" error.
117
+ */
118
+ export function chooseHubOrigin(
119
+ port: number,
120
+ env: { PARACHUTE_HUB_ORIGIN?: string | undefined } = process.env as { PARACHUTE_HUB_ORIGIN?: string },
24
121
  ): { url: string; source: McpUrlSource } {
25
122
  const hub = env.PARACHUTE_HUB_ORIGIN?.replace(/\/$/, "");
26
123
  if (hub) {
27
- return { url: `${hub}/vault/${vaultName}/mcp`, source: "hub-origin" };
124
+ return { url: hub, source: "hub-origin" };
28
125
  }
29
126
  const fqdn = readExposedFqdn();
30
127
  if (fqdn) {
31
- return { url: `https://${fqdn}/vault/${vaultName}/mcp`, source: "expose-state" };
128
+ return { url: `https://${fqdn}`, source: "expose-state" };
32
129
  }
33
- return { url: `http://127.0.0.1:${port}/vault/${vaultName}/mcp`, source: "loopback" };
130
+ return { url: `http://127.0.0.1:${port}`, source: "loopback" };
34
131
  }
35
132
 
36
133
  /**
@@ -58,3 +155,473 @@ function readExposedFqdn(): string | undefined {
58
155
  } catch {}
59
156
  return undefined;
60
157
  }
158
+
159
+ // ---------------------------------------------------------------------------
160
+ // Entry-key + URL builder (shared by preview render + write path)
161
+ // ---------------------------------------------------------------------------
162
+
163
+ /**
164
+ * Single source of truth for the MCP entry's slot key and URL. Both the
165
+ * interactive walkthrough's preview render and the writer (`executeMcpInstall`
166
+ * → `installMcpConfig`) call this so the JSON shape the operator confirms is
167
+ * the JSON shape that lands on disk. Drift between the two would silently
168
+ * mislead — they used to compute these independently (preview from
169
+ * `${ctx.hubOrigin}/vault/<name>/mcp` directly; writer through `chooseMcpUrl`).
170
+ * They agree today but a future change to one path could diverge from the
171
+ * other. vault#293.
172
+ *
173
+ * `existingEntryKey` wins when an update of a pre-existing entry is in
174
+ * progress — the walkthrough already pins this earlier in the flow; passing
175
+ * it here just keeps the preview honest about the slot the writer will
176
+ * actually use.
177
+ */
178
+ export function buildMcpEntryPlan(opts: {
179
+ vaultName: string;
180
+ vaultExplicit: boolean;
181
+ port: number;
182
+ env?: { PARACHUTE_HUB_ORIGIN?: string | undefined };
183
+ /** When updating an existing entry, the slot key the operator picked previously. */
184
+ existingEntryKey?: string;
185
+ }): { entryKey: string; url: string; source: McpUrlSource } {
186
+ const { vaultName, vaultExplicit, port, env, existingEntryKey } = opts;
187
+ const entryKey =
188
+ existingEntryKey ??
189
+ (vaultExplicit ? `parachute-vault-${vaultName}` : "parachute-vault");
190
+ const { url, source } = chooseMcpUrl(vaultName, port, env ?? (process.env as { PARACHUTE_HUB_ORIGIN?: string }));
191
+ return { entryKey, url, source };
192
+ }
193
+
194
+ // ---------------------------------------------------------------------------
195
+ // Operator-token reader
196
+ // ---------------------------------------------------------------------------
197
+
198
+ /**
199
+ * Read the operator bearer from `<root>/operator.token`. Root is
200
+ * `$PARACHUTE_HOME` if set, otherwise `~/.parachute`. Returns null when the
201
+ * file is absent or empty — caller decides whether that's a hard error.
202
+ *
203
+ * We don't enforce the 0600 mode check here (hub's reader does). The CLI
204
+ * runs locally; if the operator chmod'd it loose, that's already their
205
+ * footgun, and vault's install command isn't the place to gate on it.
206
+ */
207
+ export function readOperatorToken(env: NodeJS.ProcessEnv = process.env): string | null {
208
+ try {
209
+ const root = env.PARACHUTE_HOME ?? resolve(homedir(), ".parachute");
210
+ const path = resolve(root, "operator.token");
211
+ if (!existsSync(path)) return null;
212
+ const raw = readFileSync(path, "utf-8").trim();
213
+ return raw.length > 0 ? raw : null;
214
+ } catch {
215
+ return null;
216
+ }
217
+ }
218
+
219
+ // ---------------------------------------------------------------------------
220
+ // Hub mint-token client
221
+ // ---------------------------------------------------------------------------
222
+
223
+ /**
224
+ * Default lifetime when `--expires-in` isn't passed. Matches the hub CLI's
225
+ * default (90 days) — see `parachute-hub/src/api-mint-token.ts`.
226
+ */
227
+ export const HUB_MINT_DEFAULT_TTL_SECONDS = 90 * 24 * 60 * 60;
228
+
229
+ /**
230
+ * Result of a successful hub mint-token call. Shape mirrors the hub HTTP
231
+ * response so callers can pass `expires_at` through to logs / UX.
232
+ */
233
+ export interface MintedHubJwt {
234
+ /** The signed JWT to write into `Authorization: Bearer …`. */
235
+ token: string;
236
+ /** JTI for revocation. */
237
+ jti: string;
238
+ /** ISO timestamp at which the token expires. */
239
+ expires_at: string;
240
+ /** Whitespace-joined scope claim, mirrors the request. */
241
+ scope: string;
242
+ }
243
+
244
+ /**
245
+ * Discriminated failure modes from `mintHubJwt`. Callers turn each into a
246
+ * different operator-facing message — hub-unreachable has its own remediation
247
+ * (check `PARACHUTE_HUB_ORIGIN` / start the hub); API-error propagates the
248
+ * hub's own `error_description`.
249
+ *
250
+ * Operator-token absence is *not* a `mintHubJwt` failure mode: the caller is
251
+ * responsible for `readOperatorToken()` before invoking us — by the time we
252
+ * see `operatorToken: string`, it's guaranteed present.
253
+ */
254
+ export type MintHubJwtError =
255
+ | { kind: "network"; cause: string; origin: string }
256
+ | { kind: "api-error"; status: number; error: string; description: string };
257
+
258
+ export interface MintHubJwtOpts {
259
+ hubOrigin: string;
260
+ operatorToken: string;
261
+ scope: string;
262
+ subject?: string;
263
+ expiresInSeconds?: number;
264
+ /** Test seam — defaults to global fetch. */
265
+ fetchImpl?: typeof fetch;
266
+ }
267
+
268
+ /**
269
+ * POST to `<hub>/api/auth/mint-token`. The operator-token bearer must carry
270
+ * `parachute:host:auth` (the admin scope-set covers it). Returns the minted
271
+ * JWT or a discriminated error the caller turns into a clear message.
272
+ *
273
+ * Network errors are caught and returned as `{ kind: "network" }` rather
274
+ * than bubbling — the CLI doesn't want stack traces, and the operator wants
275
+ * to know *which* endpoint failed.
276
+ */
277
+ export async function mintHubJwt(opts: MintHubJwtOpts): Promise<MintedHubJwt | MintHubJwtError> {
278
+ const url = `${opts.hubOrigin.replace(/\/$/, "")}/api/auth/mint-token`;
279
+ const body: Record<string, unknown> = {
280
+ scope: opts.scope,
281
+ expires_in: opts.expiresInSeconds ?? HUB_MINT_DEFAULT_TTL_SECONDS,
282
+ };
283
+ if (opts.subject) body.subject = opts.subject;
284
+
285
+ const fetchFn = opts.fetchImpl ?? fetch;
286
+ let res: Response;
287
+ try {
288
+ res = await fetchFn(url, {
289
+ method: "POST",
290
+ headers: {
291
+ "Authorization": `Bearer ${opts.operatorToken}`,
292
+ "Content-Type": "application/json",
293
+ },
294
+ body: JSON.stringify(body),
295
+ });
296
+ } catch (err) {
297
+ const cause = err instanceof Error ? err.message : String(err);
298
+ return { kind: "network", cause, origin: opts.hubOrigin };
299
+ }
300
+
301
+ if (!res.ok) {
302
+ // Hub responses are JSON `{ error, error_description }`. Parse defensively
303
+ // — a misconfigured hub or a network appliance returning HTML for 502s
304
+ // shouldn't crash the CLI; we'll surface what we got.
305
+ let error = "unknown_error";
306
+ let description = `HTTP ${res.status}`;
307
+ try {
308
+ const payload = (await res.json()) as { error?: unknown; error_description?: unknown };
309
+ if (typeof payload.error === "string") error = payload.error;
310
+ if (typeof payload.error_description === "string") description = payload.error_description;
311
+ } catch {}
312
+ return { kind: "api-error", status: res.status, error, description };
313
+ }
314
+
315
+ const payload = (await res.json()) as Partial<MintedHubJwt>;
316
+ if (
317
+ typeof payload.token !== "string" ||
318
+ typeof payload.jti !== "string" ||
319
+ typeof payload.expires_at !== "string" ||
320
+ typeof payload.scope !== "string"
321
+ ) {
322
+ return {
323
+ kind: "api-error",
324
+ status: res.status,
325
+ error: "malformed_response",
326
+ description: "hub mint-token response is missing required fields (token/jti/expires_at/scope)",
327
+ };
328
+ }
329
+ return {
330
+ token: payload.token,
331
+ jti: payload.jti,
332
+ expires_at: payload.expires_at,
333
+ scope: payload.scope,
334
+ };
335
+ }
336
+
337
+ // ---------------------------------------------------------------------------
338
+ // Install target resolver
339
+ // ---------------------------------------------------------------------------
340
+
341
+ export type InstallScope = "user" | "project" | "local";
342
+
343
+ export interface InstallTarget {
344
+ /** Absolute path the install will write to. */
345
+ path: string;
346
+ /** Which scope the path corresponds to (for log lines + doctor). */
347
+ scope: InstallScope;
348
+ /**
349
+ * For `local` scope: the absolute CWD the entry is keyed under inside
350
+ * `~/.claude.json`'s `projects` map. Undefined for `user` and `project`.
351
+ */
352
+ localProjectKey?: string;
353
+ }
354
+
355
+ /**
356
+ * Pick the MCP client config file path based on `--install-scope`. Three
357
+ * shapes:
358
+ *
359
+ * user → `~/.claude.json` top-level `mcpServers` (global, every project).
360
+ * project → `<cwd>/.mcp.json` (Claude Code convention; check into the repo).
361
+ * local → `~/.claude.json` under `projects[<absolute-cwd>].mcpServers`
362
+ * (private to this machine, scoped to this directory). Matches
363
+ * Claude's own `claude mcp add` default.
364
+ *
365
+ * `homedir()` from node:os is cached at process start on Bun, so in-process
366
+ * `process.env.HOME` overrides don't propagate to it. We prefer the
367
+ * (mutable) env var when set so tests that flip `HOME` see the override
368
+ * without subprocess-spawning. Falls back to `homedir()` for the
369
+ * common case where neither tests nor exotic chrooting touches HOME.
370
+ */
371
+ export function resolveInstallTarget(
372
+ scope: InstallScope,
373
+ cwd: string = process.cwd(),
374
+ ): InstallTarget {
375
+ if (scope === "project") {
376
+ return { path: resolve(cwd, ".mcp.json"), scope: "project" };
377
+ }
378
+ const home = process.env.HOME ?? homedir();
379
+ const claudeJson = resolve(home, ".claude.json");
380
+ if (scope === "local") {
381
+ return {
382
+ path: claudeJson,
383
+ scope: "local",
384
+ localProjectKey: resolve(cwd),
385
+ };
386
+ }
387
+ return { path: claudeJson, scope: "user" };
388
+ }
389
+
390
+ // ---------------------------------------------------------------------------
391
+ // Context detection — feeds the interactive walkthrough's smart defaults
392
+ // ---------------------------------------------------------------------------
393
+
394
+ /**
395
+ * "Is this a project directory?" heuristic. Looks for any of the common
396
+ * project-root markers in the supplied directory (defaults to CWD):
397
+ *
398
+ * .git — git checkout (the strong signal)
399
+ * package.json — Node/Bun project
400
+ * pyproject.toml — Python project (modern)
401
+ * Cargo.toml — Rust project
402
+ * go.mod — Go module
403
+ * deno.json — Deno project
404
+ * .parachute — Parachute config dir present (matches our own convention)
405
+ *
406
+ * The detection is intentionally shallow — only the supplied directory,
407
+ * not its ancestors. Walking up to find a marker would create surprising
408
+ * defaults from arbitrary subdirectories ("why does installing from
409
+ * ~/code/myproject/subdir behave like ~/code/myproject?"). The operator
410
+ * can always opt explicitly with `--install-scope project` from anywhere.
411
+ */
412
+ export function detectProjectContext(cwd: string = process.cwd()): boolean {
413
+ const markers = [
414
+ ".git",
415
+ "package.json",
416
+ "pyproject.toml",
417
+ "Cargo.toml",
418
+ "go.mod",
419
+ "deno.json",
420
+ ".parachute",
421
+ ];
422
+ for (const marker of markers) {
423
+ if (existsSync(resolve(cwd, marker))) return true;
424
+ }
425
+ return false;
426
+ }
427
+
428
+ /**
429
+ * Shape of an existing vault MCP entry the interactive walkthrough cares
430
+ * about. Used to default the "update where it is" branch in the install-
431
+ * scope prompt without re-reading the same files multiple times.
432
+ */
433
+ export interface ExistingMcpEntry {
434
+ /** Absolute path of the config file the entry lives in. */
435
+ path: string;
436
+ /** Display label for prompts (~/.claude.json or ./.mcp.json). */
437
+ label: string;
438
+ /** Whether the entry sits in user or project scope. */
439
+ scope: InstallScope;
440
+ /** `mcpServers` key the entry occupies. */
441
+ entryKey: string;
442
+ /** The URL field of the entry (operator-visible state). */
443
+ url: string;
444
+ /** Whether the entry has an Authorization header. */
445
+ hasAuth: boolean;
446
+ }
447
+
448
+ /**
449
+ * Look for an existing parachute-vault entry across the three scopes:
450
+ * user — `~/.claude.json` top-level `mcpServers`.
451
+ * local — `~/.claude.json` under `projects[<cwd>].mcpServers`.
452
+ * project — `<cwd>/.mcp.json`.
453
+ *
454
+ * Returns each matching entry independently so the walkthrough can present
455
+ * the most relevant one to the operator.
456
+ *
457
+ * `parachute-vault` (singular) wins over `parachute-vault-<name>` at the
458
+ * same file — the singular slot is the canonical default install; per-
459
+ * vault keys are the multi-vault add-ons.
460
+ */
461
+ export function detectExistingEntries(
462
+ cwd: string = process.cwd(),
463
+ ): { user?: ExistingMcpEntry; local?: ExistingMcpEntry; project?: ExistingMcpEntry } {
464
+ const userTarget = resolveInstallTarget("user");
465
+ const localTarget = resolveInstallTarget("local", cwd);
466
+ const projectTarget = resolveInstallTarget("project", cwd);
467
+ const userConfig = readJsonOrNull(userTarget.path);
468
+ return {
469
+ ...maybeUserEntry(userConfig, userTarget.path),
470
+ ...maybeLocalEntry(userConfig, localTarget.path, localTarget.localProjectKey!),
471
+ ...maybeProjectEntry(projectTarget.path, `${cwd}/.mcp.json`),
472
+ };
473
+
474
+ function readJsonOrNull(p: string): any {
475
+ if (!existsSync(p)) return null;
476
+ try {
477
+ return JSON.parse(readFileSync(p, "utf-8"));
478
+ } catch {
479
+ return null;
480
+ }
481
+ }
482
+
483
+ function pickEntry(servers: Record<string, any>): { entry: any; entryKey: string } | null {
484
+ let entry = servers["parachute-vault"];
485
+ let entryKey = "parachute-vault";
486
+ if (!entry) {
487
+ for (const key of Object.keys(servers)) {
488
+ if (key.startsWith("parachute-vault-")) {
489
+ entry = servers[key];
490
+ entryKey = key;
491
+ break;
492
+ }
493
+ }
494
+ }
495
+ if (!entry || typeof entry.url !== "string") return null;
496
+ return { entry, entryKey };
497
+ }
498
+
499
+ function maybeUserEntry(config: any, p: string): { user?: ExistingMcpEntry } | {} {
500
+ if (!config) return {};
501
+ const servers: Record<string, any> = config?.mcpServers ?? {};
502
+ const picked = pickEntry(servers);
503
+ if (!picked) return {};
504
+ return {
505
+ user: {
506
+ path: p,
507
+ label: "~/.claude.json",
508
+ scope: "user",
509
+ entryKey: picked.entryKey,
510
+ url: picked.entry.url,
511
+ hasAuth: Boolean(picked.entry.headers?.Authorization),
512
+ },
513
+ };
514
+ }
515
+
516
+ function maybeLocalEntry(
517
+ config: any,
518
+ p: string,
519
+ projectKey: string,
520
+ ): { local?: ExistingMcpEntry } | {} {
521
+ if (!config) return {};
522
+ const servers: Record<string, any> = config?.projects?.[projectKey]?.mcpServers ?? {};
523
+ const picked = pickEntry(servers);
524
+ if (!picked) return {};
525
+ return {
526
+ local: {
527
+ path: p,
528
+ label: `~/.claude.json (projects["${projectKey}"])`,
529
+ scope: "local",
530
+ entryKey: picked.entryKey,
531
+ url: picked.entry.url,
532
+ hasAuth: Boolean(picked.entry.headers?.Authorization),
533
+ },
534
+ };
535
+ }
536
+
537
+ function maybeProjectEntry(p: string, label: string): { project?: ExistingMcpEntry } | {} {
538
+ const config = readJsonOrNull(p);
539
+ if (!config) return {};
540
+ const servers: Record<string, any> = config?.mcpServers ?? {};
541
+ const picked = pickEntry(servers);
542
+ if (!picked) return {};
543
+ return {
544
+ project: {
545
+ path: p,
546
+ label,
547
+ scope: "project",
548
+ entryKey: picked.entryKey,
549
+ url: picked.entry.url,
550
+ hasAuth: Boolean(picked.entry.headers?.Authorization),
551
+ },
552
+ };
553
+ }
554
+ }
555
+
556
+ /**
557
+ * Snapshot of everything the interactive walkthrough needs to pick smart
558
+ * defaults. Computed once at the start of the flow so each prompt's
559
+ * reasoning is consistent (no surprise mid-flow re-reads).
560
+ */
561
+ export interface InstallContext {
562
+ /** All vault names declared on this host. */
563
+ vaults: string[];
564
+ /** The vault `default_vault` config points at (or first vault, or "default"). */
565
+ defaultVault: string;
566
+ /** Whether a hub origin is configured beyond the loopback fallback. */
567
+ hubReachable: boolean;
568
+ /** The resolved hub origin (loopback if no hub configured). */
569
+ hubOrigin: string;
570
+ /**
571
+ * Vault listen port. Carried on the context so the preview's
572
+ * `buildMcpEntryPlan` call resolves the MCP URL through the same
573
+ * `chooseMcpUrl` path the writer uses. Without this, preview was building
574
+ * the URL from `${hubOrigin}/vault/<name>/mcp` directly — coincidentally
575
+ * identical today but liable to drift.
576
+ */
577
+ port: number;
578
+ /**
579
+ * Environment snapshot used for hub-origin resolution. Held on the
580
+ * context so the preview's URL build sees the same `PARACHUTE_HUB_ORIGIN`
581
+ * the writer will see (tests can override deterministically).
582
+ */
583
+ env: { PARACHUTE_HUB_ORIGIN?: string | undefined };
584
+ /** Whether `~/.parachute/operator.token` exists and is non-empty. */
585
+ operatorTokenPresent: boolean;
586
+ /** Heuristic: is CWD a project directory? */
587
+ inProjectContext: boolean;
588
+ /** Where the walkthrough was invoked from. */
589
+ cwd: string;
590
+ /** Pre-existing entries at user / local / project scope, if any. */
591
+ existing: { user?: ExistingMcpEntry; local?: ExistingMcpEntry; project?: ExistingMcpEntry };
592
+ }
593
+
594
+ /**
595
+ * Build an `InstallContext` from the current process + filesystem. Pure-
596
+ * function-shaped (takes everything it needs as args with sensible
597
+ * defaults), so tests can synthesize alternate contexts without monkey-
598
+ * patching globals.
599
+ */
600
+ export function detectInstallContext(opts: {
601
+ vaults: string[];
602
+ defaultVault: string;
603
+ port: number;
604
+ env?: NodeJS.ProcessEnv;
605
+ cwd?: string;
606
+ }): InstallContext {
607
+ const env = opts.env ?? process.env;
608
+ const cwd = opts.cwd ?? process.cwd();
609
+ // Narrow to chooseHubOrigin's expected shape — NodeJS.ProcessEnv is a
610
+ // string-index type that doesn't structurally match the explicit shape
611
+ // chooseHubOrigin declares; passing a sliced view sidesteps the
612
+ // structural-incompatibility complaint without losing safety.
613
+ const hubEnv = { PARACHUTE_HUB_ORIGIN: env.PARACHUTE_HUB_ORIGIN };
614
+ const hub = chooseHubOrigin(opts.port, hubEnv);
615
+ return {
616
+ vaults: opts.vaults,
617
+ defaultVault: opts.defaultVault,
618
+ hubReachable: hub.source !== "loopback",
619
+ hubOrigin: hub.url,
620
+ port: opts.port,
621
+ env: hubEnv,
622
+ operatorTokenPresent: readOperatorToken(env) !== null,
623
+ inProjectContext: detectProjectContext(cwd),
624
+ cwd,
625
+ existing: detectExistingEntries(cwd),
626
+ };
627
+ }
package/src/routes.ts CHANGED
@@ -14,6 +14,7 @@
14
14
  import type { Store, Note } from "../core/src/types.ts";
15
15
  import { listUnresolvedWikilinks } from "../core/src/wikilinks.ts";
16
16
  import { toNoteIndex, filterMetadata, MAX_BATCH_SIZE } from "../core/src/notes.ts";
17
+ import { attachValidationStatus } from "../core/src/mcp.ts";
17
18
  import * as linkOps from "../core/src/links.ts";
18
19
  import * as tagSchemaOps from "../core/src/tag-schemas.ts";
19
20
  import {
@@ -733,7 +734,13 @@ export async function handleNotes(
733
734
  }
734
735
  }
735
736
 
736
- return json(body.notes ? created : created[0], 201);
737
+ // Attach `validation_status` so HTTP create-note matches the MCP
738
+ // surface (vault#287). Mirrors the MCP create-note attach site at
739
+ // `core/src/mcp.ts:451`. `attachValidationStatus` returns the note
740
+ // unchanged when no tag declares fields, so vaults without any tag
741
+ // schemas see no behavior change.
742
+ const final = created.map((n) => attachValidationStatus(store, db, n));
743
+ return json(body.notes ? final : final[0], 201);
737
744
  }
738
745
 
739
746
  return json({ error: "Method not allowed" }, 405);
@@ -1019,11 +1026,20 @@ export async function handleNotes(
1019
1026
  // Response shape: full Note (back-compat default) or lean NoteIndex
1020
1027
  // (vault#285 friction point 2.response — opt-out for callers making
1021
1028
  // frequent small edits to large notes). Mirror the MCP `update-note`
1022
- // `include_content` knob exactly.
1029
+ // `include_content` knob exactly, *and* `validation_status` attachment
1030
+ // (vault#287) so HTTP and MCP consumers see the same schema-validation
1031
+ // signal. Recipe matches `core/src/mcp.ts:751` — attach to the full
1032
+ // Note first, then carry the field across the lean conversion (since
1033
+ // `toNoteIndex` drops unknown fields).
1023
1034
  const updatedNote = await store.getNote(note.id);
1024
1035
  if (updatedNote === null) return json({ error: "Note disappeared" }, 404);
1036
+ const validated = attachValidationStatus(store, db, updatedNote);
1025
1037
  const includeContentResp = body.include_content !== false;
1026
- return json(includeContentResp ? updatedNote : toNoteIndex(updatedNote));
1038
+ if (includeContentResp) return json(validated);
1039
+ const lean: any = toNoteIndex(validated);
1040
+ const vs = (validated as any).validation_status;
1041
+ if (vs !== undefined) lean.validation_status = vs;
1042
+ return json(lean);
1027
1043
  } catch (e: any) {
1028
1044
  if (e instanceof NotFoundError) return json({ error: e.message }, 404);
1029
1045
  // Duck-type on `code` rather than `instanceof ConflictError`: this