@nimbus-dev/connectors 0.4.0 → 0.6.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.
@@ -0,0 +1,178 @@
1
+ /**
2
+ * Pure helpers for the LaunchDarkly flag tools: the one-environment read projection and the
3
+ * rollout-weight arithmetic. Kept apart from the tool registration so both are testable alone.
4
+ */
5
+
6
+ /** LaunchDarkly rollout weights are thousandths of a percent: 0..100000, and a flag sums to 100000. */
7
+ export const FULL_ROLLOUT = 100_000;
8
+
9
+ export interface RolloutWeightInput {
10
+ readonly variationId: string;
11
+ readonly percent: number;
12
+ }
13
+
14
+ /**
15
+ * `weights` as the `rolloutWeights` map the semantic patch takes (variation id -> thousandths of a
16
+ * percent). Fails closed: duplicate ids, a percent with more than three decimals, or a total that is
17
+ * not EXACTLY 100% all throw before any request is made. (The API only says the sum "should" be 100%;
18
+ * what it does on a mismatch is not stated, so this connector does not find out on a production flag.)
19
+ */
20
+ export function rolloutWeightsOf(weights: readonly RolloutWeightInput[]): Record<string, number> {
21
+ const out: Record<string, number> = {};
22
+ let total = 0;
23
+ for (const w of weights) {
24
+ if (Object.hasOwn(out, w.variationId)) {
25
+ throw new Error(`variationId ${w.variationId} appears more than once`);
26
+ }
27
+ const thousandths = Math.round(w.percent * 1000);
28
+ if (Math.abs(w.percent * 1000 - thousandths) > 1e-6) {
29
+ throw new Error(`percent ${String(w.percent)} has more than 3 decimal places`);
30
+ }
31
+ out[w.variationId] = thousandths;
32
+ total += thousandths;
33
+ }
34
+ if (total !== FULL_ROLLOUT) {
35
+ throw new Error(
36
+ `weights must sum to exactly 100 percent, got ${String(total / 1000)}; nothing was changed`,
37
+ );
38
+ }
39
+ return out;
40
+ }
41
+
42
+ /** The `on` a flag-change reply shows for `environmentKey`, when it is a boolean. */
43
+ export function echoedOn(response: unknown, environmentKey: string): boolean | undefined {
44
+ const envs =
45
+ isRecord(response) && isRecord(response["environments"]) ? response["environments"] : {};
46
+ const env = envs[environmentKey];
47
+ return isRecord(env) && typeof env["on"] === "boolean" ? env["on"] : undefined;
48
+ }
49
+
50
+ /** `environmentKey`'s fallthrough rollout object in a flag-change reply, when there is one. */
51
+ function fallthroughRolloutOf(
52
+ response: Record<string, unknown>,
53
+ environmentKey: string,
54
+ ): Record<string, unknown> | undefined {
55
+ const envs = isRecord(response["environments"]) ? response["environments"] : {};
56
+ const env = envs[environmentKey];
57
+ const fallthrough = isRecord(env) && isRecord(env["fallthrough"]) ? env["fallthrough"] : {};
58
+ const rollout = fallthrough["rollout"];
59
+ return isRecord(rollout) ? rollout : undefined;
60
+ }
61
+
62
+ /**
63
+ * The echoed rollout weights by variation id, or `undefined` when any entry cannot be resolved to a
64
+ * variation id and a numeric weight.
65
+ */
66
+ function echoedWeights(
67
+ rollout: Record<string, unknown>,
68
+ variations: readonly unknown[],
69
+ ): Record<string, number> | undefined {
70
+ const echoed: Record<string, number> = {};
71
+ const entries = Array.isArray(rollout["variations"]) ? rollout["variations"] : [];
72
+ for (const entry of entries) {
73
+ const row = isRecord(entry) ? entry : {};
74
+ const index = row["variation"];
75
+ const weight = row["weight"];
76
+ const variation = typeof index === "number" ? variations[index] : undefined;
77
+ const id = isRecord(variation) ? variation["_id"] : undefined;
78
+ if (typeof id !== "string" || typeof weight !== "number") return undefined;
79
+ echoed[id] = weight;
80
+ }
81
+ return echoed;
82
+ }
83
+
84
+ /**
85
+ * True only when the reply carries `environmentKey`'s fallthrough rollout and its weights (by
86
+ * variation id, a missing id counting as 0) equal `requested`. A reply without them is not a
87
+ * confirmation.
88
+ */
89
+ export function rolloutEchoed(
90
+ response: unknown,
91
+ environmentKey: string,
92
+ requested: Readonly<Record<string, number>>,
93
+ ): boolean {
94
+ if (!isRecord(response)) return false;
95
+ const rollout = fallthroughRolloutOf(response, environmentKey);
96
+ if (rollout === undefined || !Array.isArray(rollout["variations"])) return false;
97
+ const variations = Array.isArray(response["variations"]) ? response["variations"] : [];
98
+ const echoed = echoedWeights(rollout, variations);
99
+ if (echoed === undefined) return false;
100
+ const ids = new Set([...Object.keys(requested), ...Object.keys(echoed)]);
101
+ for (const id of ids) {
102
+ if ((requested[id] ?? 0) !== (echoed[id] ?? 0)) return false;
103
+ }
104
+ return true;
105
+ }
106
+
107
+ function isRecord(v: unknown): v is Record<string, unknown> {
108
+ return v !== null && typeof v === "object" && !Array.isArray(v);
109
+ }
110
+
111
+ function numberOrUndefined(v: unknown): number | undefined {
112
+ return typeof v === "number" ? v : undefined;
113
+ }
114
+
115
+ /**
116
+ * The state of one flag in one environment, from `GET /flags/{p}/{f}?env={e}`.
117
+ *
118
+ * Variation references (`fallthrough.variation`, `offVariation`, `rollout.variations[].variation`)
119
+ * are read as INDICES into `variations[]`, which is how the schema describes `fallthrough.variation`.
120
+ * The same schema calls `offVariation` an "ID" in one place; the raw number is returned beside the
121
+ * resolved id so a caller can see which reading applied. Pinned by a fixture; live-check item.
122
+ */
123
+ export function projectFlagState(
124
+ raw: unknown,
125
+ ids: { projectKey: string; flagKey: string; environmentKey: string },
126
+ ): Record<string, unknown> {
127
+ const flag = isRecord(raw) ? raw : {};
128
+ const envs = isRecord(flag["environments"]) ? flag["environments"] : {};
129
+ const env = envs[ids.environmentKey];
130
+ if (!isRecord(env)) {
131
+ throw new Error(`LaunchDarkly environment not found: ${ids.environmentKey}`);
132
+ }
133
+ const rawVariations = Array.isArray(flag["variations"]) ? flag["variations"] : [];
134
+ const variations = rawVariations.map((v, index) => {
135
+ const row = isRecord(v) ? v : {};
136
+ return { index, id: row["_id"], name: row["name"], value: row["value"] };
137
+ });
138
+ const idAt = (index: unknown): unknown =>
139
+ typeof index === "number" ? variations[index]?.id : undefined;
140
+
141
+ const fallthroughRaw = isRecord(env["fallthrough"]) ? env["fallthrough"] : {};
142
+ const fallthrough: Record<string, unknown> = {};
143
+ if (typeof fallthroughRaw["variation"] === "number") {
144
+ fallthrough["variation"] = fallthroughRaw["variation"];
145
+ fallthrough["variationId"] = idAt(fallthroughRaw["variation"]);
146
+ }
147
+ const rollout = fallthroughRaw["rollout"];
148
+ if (isRecord(rollout) && Array.isArray(rollout["variations"])) {
149
+ fallthrough["rollout"] = rollout["variations"].map((entry) => {
150
+ const row = isRecord(entry) ? entry : {};
151
+ const weight = numberOrUndefined(row["weight"]);
152
+ return {
153
+ variation: row["variation"],
154
+ variationId: idAt(row["variation"]),
155
+ weightPercent: weight === undefined ? undefined : weight / 1000,
156
+ };
157
+ });
158
+ }
159
+ if (isRecord(rollout)) {
160
+ if (typeof rollout["bucketBy"] === "string")
161
+ fallthrough["rolloutBucketBy"] = rollout["bucketBy"];
162
+ if (typeof rollout["contextKind"] === "string") {
163
+ fallthrough["rolloutContextKind"] = rollout["contextKind"];
164
+ }
165
+ }
166
+ return {
167
+ projectKey: ids.projectKey,
168
+ flagKey: ids.flagKey,
169
+ environmentKey: ids.environmentKey,
170
+ on: env["on"],
171
+ offVariation: env["offVariation"],
172
+ offVariationId: idAt(env["offVariation"]),
173
+ fallthrough,
174
+ variations,
175
+ version: numberOrUndefined(env["version"]) ?? numberOrUndefined(flag["_version"]),
176
+ archived: flag["archived"],
177
+ };
178
+ }
@@ -1,6 +1,14 @@
1
- import { runReadOnlyMcpConnector } from "../../../shared/run-read-only-mcp-connector.ts";
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
3
+ import { CONSENT_SERVER_CAPABILITIES } from "../../../shared/consent-kit.ts";
2
4
  import { registerLaunchdarklyTools } from "./tools.ts";
3
5
 
4
- await runReadOnlyMcpConnector("nimbus-launchdarkly", (reg) => {
5
- registerLaunchdarklyTools(reg);
6
- });
6
+ const server = new McpServer(
7
+ { name: "nimbus-launchdarkly", version: "0.1.0" },
8
+ { capabilities: CONSENT_SERVER_CAPABILITIES },
9
+ );
10
+
11
+ registerLaunchdarklyTools(server);
12
+
13
+ const transport = new StdioServerTransport();
14
+ await server.connect(transport);
@@ -1,32 +1,133 @@
1
1
  import { z } from "zod";
2
- import { createJsonGetter, envAuthHeaders, optionalEnv } from "../../../shared/env-json-api.ts";
2
+ import { type ConsentServer, createWriteToolRegistrar } from "../../../shared/consent-kit.ts";
3
+ import {
4
+ createJsonGetter,
5
+ createJsonSender,
6
+ envAuthHeaders,
7
+ type JsonApiConfig,
8
+ optionalEnv,
9
+ } from "../../../shared/env-json-api.ts";
3
10
  import { matchesResult } from "../../../shared/mcp-search-tool.ts";
4
- import { mcpJsonResult as jsonResult } from "../../../shared/mcp-tool-kit.ts";
5
- import type { ZodToolRegistrar } from "../../../shared/run-read-only-mcp-connector.ts";
11
+ import {
12
+ createRegisterSimpleTool,
13
+ createZodToolRegistrar,
14
+ mcpJsonResult as jsonResult,
15
+ } from "../../../shared/mcp-tool-kit.ts";
16
+ import { echoedOn, projectFlagState, rolloutEchoed, rolloutWeightsOf } from "./flag-state.ts";
6
17
  import { filterLaunchDarklyFlags } from "./search-filter.ts";
7
18
 
8
19
  const DEFAULT_BASE = "https://app.launchdarkly.com";
9
20
 
10
- const ldGet = createJsonGetter({
21
+ const api: JsonApiConfig = {
11
22
  // `optionalEnv`, not `optionalBaseUrl`: a trailing slash on the override is left as given.
12
23
  base: () => `${optionalEnv("LAUNCHDARKLY_BASE_URL", DEFAULT_BASE)}/api/v2`,
13
24
  label: "LaunchDarkly",
14
25
  headers: envAuthHeaders({ env: "LAUNCHDARKLY_TOKEN", scheme: "" }),
15
- });
26
+ };
27
+
28
+ const ldGet = createJsonGetter(api);
29
+ const ldSend = createJsonSender(api);
30
+
31
+ /**
32
+ * A semantic patch needs `domain-model=launchdarkly.semanticpatch` appended to Content-Type, else
33
+ * LaunchDarkly treats the body as a JSON patch and answers 400
34
+ * (https://launchdarkly.com/docs/guides/api/rest-api, "append `domain-model=launchdarkly.semanticpatch`
35
+ * to your `Content-Type` header").
36
+ */
37
+ const SEMANTIC_PATCH_HEADERS = {
38
+ "Content-Type": "application/json; domain-model=launchdarkly.semanticpatch",
39
+ } as const;
40
+
41
+ /** Written into the flag's change history, so the audit trail says who made the change. */
42
+ const CHANGE_COMMENT = "Changed by Nimbus after owner approval";
16
43
 
17
44
  /** Tool names exposed by this connector — for contract/introspection tests. */
18
45
  export const LAUNCHDARKLY_TOOL_NAMES = [
19
46
  "launchdarkly_list",
20
47
  "launchdarkly_get",
21
48
  "launchdarkly_search",
49
+ "launchdarkly_flag_state_get",
50
+ "launchdarkly_flag_toggle",
51
+ "launchdarkly_flag_rollout_set",
22
52
  ] as const;
23
53
 
24
- export function registerLaunchdarklyTools(reg: ZodToolRegistrar): void {
54
+ /**
55
+ * LaunchDarkly project, environment and flag keys: letters, digits, `.`, `_`, `-`, but never `.` or
56
+ * `..` alone: `encodeURIComponent("..")` is `..`, and a URL parser collapses it out of the path.
57
+ */
58
+ const keyArg = z
59
+ .string()
60
+ .regex(
61
+ /^(?!\.{1,2}$)[A-Za-z0-9._-]{1,128}$/,
62
+ "letters, digits, '.', '_' and '-' only; not '.' or '..'",
63
+ );
64
+
65
+ /** The read tools keep accepting any key (it is percent-encoded), but never a dot segment. */
66
+ const readKeyArg = z
67
+ .string()
68
+ .min(1)
69
+ .regex(/^(?!\.{1,2}$)/, "not '.' or '..'");
70
+
71
+ const flagArgs = z.object({
72
+ projectKey: keyArg,
73
+ environmentKey: keyArg,
74
+ flagKey: keyArg,
75
+ });
76
+
77
+ type FlagArgs = z.infer<typeof flagArgs>;
78
+
79
+ function flagPath(p: { projectKey: string; flagKey: string }): string {
80
+ return `/flags/${encodeURIComponent(p.projectKey)}/${encodeURIComponent(p.flagKey)}`;
81
+ }
82
+
83
+ /** One environment's slice of a flag, so the read stays small however many environments exist. */
84
+ async function readFlagState(p: FlagArgs): Promise<Record<string, unknown>> {
85
+ const search = new URLSearchParams({ env: p.environmentKey });
86
+ const raw = await ldGet(`${flagPath(p)}?${search.toString()}`);
87
+ return projectFlagState(raw, p);
88
+ }
89
+
90
+ /**
91
+ * The result of a flag change: the facts, not LaunchDarkly's whole flag document. `confirmed` is
92
+ * true only when the reply SHOWS the requested state; false means the server accepted the write
93
+ * but its reply does not show the new state.
94
+ */
95
+ function changed(
96
+ p: FlagArgs,
97
+ response: unknown,
98
+ confirmed: (response: unknown) => boolean,
99
+ ): Record<string, unknown> {
100
+ const flag = (response ?? {}) as { environments?: Record<string, { on?: unknown }> };
101
+ return {
102
+ ok: true,
103
+ confirmed: confirmed(response),
104
+ projectKey: p.projectKey,
105
+ environmentKey: p.environmentKey,
106
+ flagKey: p.flagKey,
107
+ on: flag.environments?.[p.environmentKey]?.on,
108
+ };
109
+ }
110
+
111
+ /** Scope: `flag:<projectKey>/<environmentKey>/<flagKey>`. */
112
+ function flagScope(p: FlagArgs): { kind: string; value: string } {
113
+ return { kind: "flag", value: `${p.projectKey}/${p.environmentKey}/${p.flagKey}` };
114
+ }
115
+
116
+ export function registerLaunchdarklyTools(
117
+ server: ConsentServer & { tool: (...args: never) => unknown },
118
+ ): void {
119
+ const reg = createZodToolRegistrar(createRegisterSimpleTool(server));
120
+ const registerWriteTool = createWriteToolRegistrar(server, {
121
+ connector: "launchdarkly",
122
+ scopeEnv: "NIMBUS_MCP_LAUNCHDARKLY_WRITE_SCOPE",
123
+ scopeKinds: ["flag"],
124
+ });
125
+
25
126
  reg(
26
127
  "launchdarkly_list",
27
128
  "List LaunchDarkly projects, or feature flags for a project. Without `projectKey`, returns the account's projects (`/projects`). With `projectKey`, returns that project's flags (`/flags/{projectKey}`), capped at `limit` (default 100).",
28
129
  z.object({
29
- projectKey: z.string().min(1).optional(),
130
+ projectKey: readKeyArg.optional(),
30
131
  limit: z.number().int().min(1).max(500).optional(),
31
132
  }),
32
133
  async (p) => {
@@ -44,8 +145,8 @@ export function registerLaunchdarklyTools(reg: ZodToolRegistrar): void {
44
145
  "launchdarkly_get",
45
146
  "Fetch one LaunchDarkly feature flag by project key + flag key. Throws when no match is found.",
46
147
  z.object({
47
- projectKey: z.string().min(1),
48
- flagKey: z.string().min(1),
148
+ projectKey: readKeyArg,
149
+ flagKey: readKeyArg,
49
150
  }),
50
151
  async (p) => {
51
152
  return jsonResult(
@@ -58,7 +159,7 @@ export function registerLaunchdarklyTools(reg: ZodToolRegistrar): void {
58
159
  "launchdarkly_search",
59
160
  "Substring search across a project's feature flags. Matches the query against flag key, name, description, and tags (case-insensitive). Returns a `{ matches: [...] }` envelope.",
60
161
  z.object({
61
- projectKey: z.string().min(1),
162
+ projectKey: readKeyArg,
62
163
  query: z.string().min(1),
63
164
  limit: z.number().int().min(1).max(200).optional(),
64
165
  }),
@@ -69,4 +170,77 @@ export function registerLaunchdarklyTools(reg: ZodToolRegistrar): void {
69
170
  return matchesResult(flags, filterLaunchDarklyFlags, p);
70
171
  },
71
172
  );
173
+
174
+ reg(
175
+ "launchdarkly_flag_state_get",
176
+ "Read one feature flag's state in ONE environment (`GET /flags/{projectKey}/{flagKey}?env={environmentKey}`): on/off, off variation, fallthrough (a single variation or a percentage rollout, with variation ids and weights in percent), the variation list and the environment's version. Smaller than `launchdarkly_get`, which returns every environment. Variation references are read as indices into `variations` (LaunchDarkly's schema is ambiguous for `offVariation`); the raw number is returned beside the resolved id.",
177
+ flagArgs,
178
+ async (p) => jsonResult(await readFlagState(p)),
179
+ );
180
+
181
+ registerWriteTool(
182
+ "launchdarkly_flag_toggle",
183
+ {
184
+ mutates: "launchdarkly.flag.toggle",
185
+ recoverable: true,
186
+ scopeTargetOf: flagScope,
187
+ },
188
+ "Turn a feature flag on or off in ONE environment (`PATCH /flags/{projectKey}/{flagKey}`, semantic patch turnFlagOn / turnFlagOff). Reversible by toggling back. An environment that requires approvals answers 405 and nothing changes.",
189
+ flagArgs.extend({ on: z.boolean() }),
190
+ async (p) => {
191
+ const response = await ldSend(
192
+ "PATCH",
193
+ flagPath(p),
194
+ {
195
+ environmentKey: p.environmentKey,
196
+ comment: CHANGE_COMMENT,
197
+ instructions: [{ kind: p.on ? "turnFlagOn" : "turnFlagOff" }],
198
+ },
199
+ SEMANTIC_PATCH_HEADERS,
200
+ );
201
+ return jsonResult(changed(p, response, (r) => echoedOn(r, p.environmentKey) === p.on));
202
+ },
203
+ );
204
+
205
+ registerWriteTool(
206
+ "launchdarkly_flag_rollout_set",
207
+ {
208
+ mutates: "launchdarkly.flag.rollout.set",
209
+ // The previous fallthrough (variation or weights, bucketBy, context kind) is recorded in the
210
+ // audit pre-state. This tool does not restore it, so the write is not marked recoverable.
211
+ recoverable: false,
212
+ capturePreState: readFlagState,
213
+ scopeTargetOf: flagScope,
214
+ },
215
+ "Set a feature flag's fallthrough to a percentage rollout in ONE environment (`PATCH /flags/{projectKey}/{flagKey}`, semantic patch updateFallthroughVariationOrRollout). `weights` maps variation ids (from `launchdarkly_flag_state_get`) to percent; they must sum to exactly 100 with at most 3 decimals, else nothing is sent. The previous fallthrough is recorded in the audit pre-state; this tool does not restore it, and a rollout's bucketBy / context kind are not set by it.",
216
+ flagArgs.extend({
217
+ weights: z
218
+ .array(
219
+ z.object({
220
+ variationId: z.string().regex(/^[A-Za-z0-9._-]{1,128}$/),
221
+ percent: z.number().min(0).max(100),
222
+ }),
223
+ )
224
+ .min(1)
225
+ .max(20),
226
+ }),
227
+ async (p) => {
228
+ // Validated here, not with `.refine` on the schema: a refined schema loses `.shape`, which
229
+ // the registrar passes to the MCP SDK as the tool's input schema.
230
+ const rolloutWeights = rolloutWeightsOf(p.weights);
231
+ const response = await ldSend(
232
+ "PATCH",
233
+ flagPath(p),
234
+ {
235
+ environmentKey: p.environmentKey,
236
+ comment: CHANGE_COMMENT,
237
+ instructions: [{ kind: "updateFallthroughVariationOrRollout", rolloutWeights }],
238
+ },
239
+ SEMANTIC_PATCH_HEADERS,
240
+ );
241
+ return jsonResult(
242
+ changed(p, response, (r) => rolloutEchoed(r, p.environmentKey, rolloutWeights)),
243
+ );
244
+ },
245
+ );
72
246
  }
@@ -2,7 +2,7 @@
2
2
  "id": "com.nimbus.obsidian",
3
3
  "displayName": "Obsidian",
4
4
  "version": "0.1.0",
5
- "description": "Local Obsidian vault connector (notes, backlinks). No network. Vault paths come from [[filesystem.roots]] and are merged into permissions.filesystem at spawn time by the Gateway. obsidian_append_to_daily_note requires HITL.",
5
+ "description": "Local Obsidian vault connector (notes, backlinks, daily notes). No network. Vault paths come from [[filesystem.roots]] and are merged into permissions.filesystem.read at spawn time by the Gateway, which may also add the vaults to permissions.filesystem.write at spawn (only while the owner has approved obsidian_append_to_daily_note for the model). obsidian_append_to_daily_note requires HITL; obsidian_daily_note_get is a read.",
6
6
  "author": "Nimbus",
7
7
  "entrypoint": "dist/server.js",
8
8
  "runtime": "bun",
@@ -2,11 +2,13 @@ import { createHash } from "node:crypto";
2
2
  import {
3
3
  closeSync,
4
4
  fstatSync,
5
+ lstatSync,
5
6
  mkdirSync,
6
7
  openSync,
7
8
  readdirSync,
8
9
  readFileSync,
9
10
  readSync,
11
+ realpathSync,
10
12
  statSync,
11
13
  writeSync,
12
14
  } from "node:fs";
@@ -54,6 +56,93 @@ function assertWithinVault(vaultRoot: string, relPath: string): string {
54
56
  return candidate;
55
57
  }
56
58
 
59
+ /** Whether anything, a dangling link included, sits at `p`. */
60
+ function pathEntryExists(p: string): boolean {
61
+ try {
62
+ lstatSync(p);
63
+ return true;
64
+ } catch {
65
+ return false;
66
+ }
67
+ }
68
+
69
+ /**
70
+ * Refuse `abs` (a path `assertWithinVault` already accepted) when the place it really names is
71
+ * outside the vault: a folder or note that is, or passes through, a symlink or junction out of the
72
+ * vault. `assertWithinVault` compares strings, so it cannot see a link.
73
+ *
74
+ * The note, and the folders above it, may not exist yet (the append creates them), so the check
75
+ * resolves the deepest part of the path that does exist; whatever is created below it is created
76
+ * inside it. A dangling link cannot be resolved and is refused. The check and the later open are
77
+ * two system calls, so a link swapped in between them is not seen here: the gateway's sandbox,
78
+ * which grants only the vaults, is the boundary (stated bound).
79
+ */
80
+ export function assertRealpathWithinVault(vaultRoot: string, abs: string, relPath: string): void {
81
+ const realRoot = realpathSync(resolve(vaultRoot));
82
+ const prefix = realRoot.endsWith(sep) ? realRoot : realRoot + sep;
83
+ let probe = abs;
84
+ while (!pathEntryExists(probe)) {
85
+ const parent = dirname(probe);
86
+ if (parent === probe) break;
87
+ probe = parent;
88
+ }
89
+ let real: string;
90
+ try {
91
+ real = realpathSync(probe);
92
+ } catch {
93
+ throw new Error(`Path escapes vault: ${relPath}`);
94
+ }
95
+ if (real !== realRoot && !real.startsWith(prefix)) {
96
+ throw new Error(`Path escapes vault: ${relPath}`);
97
+ }
98
+ }
99
+
100
+ /** How much of the end of a daily note `obsidian_daily_note_get` returns. */
101
+ export const DAILY_NOTE_TAIL_BYTES = 16 * 1024;
102
+
103
+ /** The positioned-read signature of `readSync`, injectable so a test can count what is read. */
104
+ export type PositionedRead = (
105
+ fd: number,
106
+ buffer: Buffer,
107
+ offset: number,
108
+ length: number,
109
+ position: number,
110
+ ) => number;
111
+
112
+ /**
113
+ * The size of the file at `abs` and its last `maxBytes` bytes, decoded as UTF-8.
114
+ *
115
+ * Positioned reads of at most `maxBytes` in all, never the whole file. When the window starts
116
+ * inside the file, the UTF-8 continuation bytes it begins with (the rest of a character the window
117
+ * cut) are dropped, so the tail starts on a character boundary.
118
+ */
119
+ export function readUtf8Tail(
120
+ abs: string,
121
+ maxBytes: number,
122
+ read: PositionedRead = readSync,
123
+ ): { size: number; tail: string } {
124
+ const fd = openSync(abs, "r");
125
+ try {
126
+ const size = fstatSync(fd).size;
127
+ const start = Math.max(0, size - maxBytes);
128
+ const length = size - start;
129
+ const buf = Buffer.alloc(length);
130
+ let got = 0;
131
+ while (got < length) {
132
+ const n = read(fd, buf, got, length - got, start + got);
133
+ if (n <= 0) break;
134
+ got += n;
135
+ }
136
+ let skip = 0;
137
+ if (start > 0) {
138
+ while (skip < 3 && skip < got && ((buf[skip] ?? 0) & 0xc0) === 0x80) skip++;
139
+ }
140
+ return { size, tail: buf.subarray(skip, got).toString("utf8") };
141
+ } finally {
142
+ closeSync(fd);
143
+ }
144
+ }
145
+
57
146
  function loadVaultPaths(): readonly string[] {
58
147
  const raw = requireProcessEnv("OBSIDIAN_VAULT_PATHS_JSON");
59
148
  let parsed: unknown;
@@ -98,7 +187,10 @@ function walkForVaults(dir: string, out: VaultEntry[]): void {
98
187
  const sub = join(dir, e);
99
188
  let isDir = false;
100
189
  try {
101
- const st = statSync(sub);
190
+ // lstat, not stat: stat follows a link, so its isSymbolicLink() was always false and
191
+ // discovery used to find a vault through a symlink or junction outside the configured root.
192
+ // Every link below a root is skipped, so a discovered vault is physically under its root.
193
+ const st = lstatSync(sub);
102
194
  isDir = st.isDirectory() && !st.isSymbolicLink();
103
195
  } catch {
104
196
  continue;
@@ -128,7 +220,9 @@ function walkNotes(currentDir: string, vaultRoot: string, out: string[]): void {
128
220
  let isFile = false;
129
221
  let isDir = false;
130
222
  try {
131
- const st = statSync(full);
223
+ // lstat, not stat: stat follows a link, so its isSymbolicLink() is always false and the
224
+ // walk used to list notes through a symlink or junction, out of the vault included.
225
+ const st = lstatSync(full);
132
226
  if (st.isSymbolicLink()) continue;
133
227
  isFile = st.isFile();
134
228
  isDir = st.isDirectory();
@@ -151,6 +245,7 @@ function readNote(
151
245
  relPath: string,
152
246
  ): { title: string; body: string; raw: string } {
153
247
  const abs = assertWithinVault(vaultRoot, relPath);
248
+ assertRealpathWithinVault(vaultRoot, abs, relPath);
154
249
  const raw = readFileSync(abs, "utf8");
155
250
  const m = FRONTMATTER_RE.exec(raw);
156
251
  const body = m === null ? raw : raw.slice(m[0].length);
@@ -193,6 +288,7 @@ export const OBSIDIAN_TOOL_NAMES = [
193
288
  "obsidian_get",
194
289
  "obsidian_search",
195
290
  "obsidian_append_to_daily_note",
291
+ "obsidian_daily_note_get",
196
292
  ] as const;
197
293
 
198
294
  /** The date tokens an Obsidian daily-note filename format may contain. */
@@ -400,6 +496,28 @@ export function registerObsidianTools(
400
496
  }),
401
497
  );
402
498
 
499
+ /**
500
+ * The vault and the daily note a daily-note tool names: ONE resolution, shared by the append and
501
+ * the read, so the read shows exactly the note the append would write. String containment
502
+ * (`assertWithinVault`) and then realpath containment (`assertRealpathWithinVault`).
503
+ */
504
+ function resolveDailyNote(parsed: { vault_id: string; date_iso?: string | undefined }): {
505
+ v: VaultEntry;
506
+ rel: string;
507
+ abs: string;
508
+ } {
509
+ const v = findVaultByIdOrPathPrefix(VAULTS, parsed.vault_id);
510
+ if (v === undefined) {
511
+ throw new Error("Unknown vault_id");
512
+ }
513
+ const date =
514
+ parsed.date_iso === undefined ? new Date() : new Date(`${parsed.date_iso}T00:00:00Z`);
515
+ const rel = resolveDailyNoteRelativePath(v.root, date);
516
+ const abs = assertWithinVault(v.root, rel);
517
+ assertRealpathWithinVault(v.root, abs, rel);
518
+ return { v, rel, abs };
519
+ }
520
+
403
521
  const appendDailyNoteSchema = z.object({
404
522
  vault_id: z.string().min(1),
405
523
  content: z.string().min(1),
@@ -420,15 +538,10 @@ export function registerObsidianTools(
420
538
  "Append text to today's Obsidian daily note. Creates the file if it does not exist. Always appends — never overwrites. Adds a leading newline when the existing file does not end in one. Requires HITL `obsidian.note.append`.",
421
539
  appendDailyNoteSchema,
422
540
  syncToolHandler((parsed) => {
423
- const v = findVaultByIdOrPathPrefix(VAULTS, parsed.vault_id);
424
- if (v === undefined) {
425
- throw new Error("Unknown vault_id");
426
- }
427
- const date =
428
- parsed.date_iso === undefined ? new Date() : new Date(`${parsed.date_iso}T00:00:00Z`);
429
- const rel = resolveDailyNoteRelativePath(v.root, date);
430
- const abs = assertWithinVault(v.root, rel);
541
+ const { v, rel, abs } = resolveDailyNote(parsed);
431
542
  mkdirSync(dirname(abs), { recursive: true });
543
+ // Again, now the folders exist: what was created must still be inside the vault.
544
+ assertRealpathWithinVault(v.root, abs, rel);
432
545
 
433
546
  const fd = openSync(abs, "a+");
434
547
  let bytes = 0;
@@ -463,4 +576,39 @@ export function registerObsidianTools(
463
576
  });
464
577
  }),
465
578
  );
579
+
580
+ const dailyNoteGetSchema = z.object({
581
+ vault_id: z.string().min(1),
582
+ date_iso: z
583
+ .string()
584
+ .regex(/^\d{4}-\d{2}-\d{2}$/)
585
+ .optional(),
586
+ });
587
+
588
+ reg(
589
+ "obsidian_daily_note_get",
590
+ `Read the end of the Obsidian daily note that obsidian_append_to_daily_note would write for the same vault_id and date_iso (today in UTC when omitted). Returns { vault_id, vault_name, path, exists, size_bytes, tail }: tail is at most the last ${String(DAILY_NOTE_TAIL_BYTES)} bytes, starting on a character boundary; a missing note is exists: false, size_bytes: 0, tail "". Read-only.`,
591
+ dailyNoteGetSchema,
592
+ syncToolHandler((parsed) => {
593
+ const { v, rel, abs } = resolveDailyNote(parsed);
594
+ let exists = false;
595
+ let size = 0;
596
+ let tail = "";
597
+ if (pathEntryExists(abs)) {
598
+ if (!statSync(abs).isFile()) {
599
+ throw new Error(`Daily note is not a file: ${rel}`);
600
+ }
601
+ exists = true;
602
+ ({ size, tail } = readUtf8Tail(abs, DAILY_NOTE_TAIL_BYTES));
603
+ }
604
+ return jsonResult({
605
+ vault_id: v.id,
606
+ vault_name: v.name,
607
+ path: rel,
608
+ exists,
609
+ size_bytes: size,
610
+ tail,
611
+ });
612
+ }),
613
+ );
466
614
  }