@panaversity/ksor 0.0.23 → 0.0.25

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,144 @@
1
+ import { CallToolResult, McpServer as McpServer$1, StandardSchemaWithJSON } from "@modelcontextprotocol/server";
2
+ import { z as z$1 } from "zod";
3
+ import pg from "pg";
4
+ //#region ../content-gateway/dist/gateway-api-D9COH1tR.d.mts
5
+ //#region src/instructions.d.ts
6
+ /**
7
+ * Has the owner said what this record is FOR yet?
8
+ *
9
+ * The MCP door already answers honestly when they have not — it replaces the
10
+ * template with a plain statement that the scope is unstated. But the operator
11
+ * starting the server was told nothing, so a record serving with no declared
12
+ * identity looked exactly like one that had been described. The boot report is
13
+ * where that belongs, beside the abstention posture: both are answers to "how
14
+ * much should I trust what this thing says".
15
+ */
16
+ declare function recordIsUndescribed(authored: string): boolean;
17
+ declare function composeInstructions(authored: string): string;
18
+ //#endregion
19
+ //#region ../content/dist/index.d.mts
20
+ //#endregion
21
+ //#region src/lib/abstain.d.ts
22
+ /**
23
+ * The abstention gates (oracle SC/lib/abstain.py, exact). "Not in this
24
+ * corpus" is a correct answer, never an error, never a licence to fall back
25
+ * on model knowledge.
26
+ *
27
+ * Floors come from the instance frontmatter, never module globals, and are
28
+ * calibrated per corpus per embedding space — never copy a calibrated
29
+ * constant between corpora.
30
+ */
31
+ interface AbstainConfig {
32
+ /**
33
+ * A calibrated number gates (abstain below it); `null` = no gate declared,
34
+ * honest absence surfaced on /health; `"uncalibrated"` = a floor was
35
+ * DECLARED but not measured, which REFUSES every serve until it is pasted
36
+ * (the "fail closed once a floor is declared" invariant, representable).
37
+ */
38
+ readonly vectorFloor: number | null | "uncalibrated";
39
+ /**
40
+ * Degraded-path ts_rank_cd floor; null = abstain only on zero matches.
41
+ * Recorded negative result (oracle, measured on 416 in-corpus gold + 38
42
+ * OOC probes): ts_rank_cd does NOT separate in/out-of-corpus — every
43
+ * leak-stopping floor false-abstained 67–98% (0.1333 → FA 255/379;
44
+ * 0.7 → FA 355/379). The shipped instance keeps keyword_floor null;
45
+ * never set one by intuition, recalibrate per corpus.
46
+ */
47
+ readonly keywordFloor: number | null;
48
+ }
49
+ interface ContentInstance {
50
+ /** The corpus identity — instance.md's `name:` (the path/name IS the identity). */
51
+ readonly name: string;
52
+ readonly corpusId: string;
53
+ readonly tenantId: string;
54
+ /** The NAME of the env var holding the DSN; the composition root resolves it. */
55
+ readonly dsnEnv: string;
56
+ readonly abstain: AbstainConfig;
57
+ readonly maximumResponseCharacters: number;
58
+ /** The authored agent-surface instructions (the body, edge-trimmed). */
59
+ readonly instructions: string;
60
+ /** Transport name (registry key, never persisted). */
61
+ readonly embeddingProvider: string;
62
+ /** The record's reader audiences, least- to most-restricted; empty = no model. */
63
+ /** The Postgres text-search configuration the keyword arm stems with. */
64
+ readonly textSearchConfig: string;
65
+ readonly audiences: readonly string[];
66
+ /** The tier a document takes when it declares none; null = none declared. */
67
+ readonly defaultVisibility: string | null;
68
+ /** model + dim are the persisted IDENTITY of the embedding space. */
69
+ readonly embeddingModel: string;
70
+ readonly embeddingDim: number;
71
+ }
72
+ interface KeyRing {
73
+ readonly keys: ReadonlyMap<string, Buffer>;
74
+ /** Mint with the active key; keep validating old ids until their tokens age out (≤ TTL). */
75
+ readonly active: string;
76
+ }
77
+ declare const MAX_SEARCH_K = 50;
78
+ interface ServiceContext {
79
+ readonly pool: pg.Pool;
80
+ readonly instance: ContentInstance;
81
+ readonly ring: KeyRing;
82
+ /** sha256 of instance.md — the deployment binding snapshots carry. */
83
+ readonly instanceDigest: string;
84
+ /** The query-embed pipeline (cache + breaker + timeout live behind it); returns a pgvector literal or a raw vector. */
85
+ readonly embedQuery: (query: string) => Promise<readonly number[] | string>;
86
+ /** The verified caller, or null → audited as "anonymous". */
87
+ readonly actor?: () => string | null;
88
+ /**
89
+ * The audience tier this door serves. null = the record's least-privileged
90
+ * tier, which is the safe default: a door that cannot establish who is asking
91
+ * must not hand out the restricted half of the record. Ignored entirely when
92
+ * the instance declares no `audiences:` model.
93
+ */
94
+ readonly audience?: string | null;
95
+ }
96
+ /**
97
+ * The largest outline a caller may ASK for. The tool schema and the service
98
+ * both derive from it, so the ceiling is one number rather than three
99
+ * hand-copied ones.
100
+ */
101
+ declare const MAX_OUTLINE_LIMIT = 5e3;
102
+ //#endregion
103
+ //#region src/tools.d.ts
104
+ /**
105
+ * The framework text every tool description must carry.
106
+ *
107
+ * A registration file puts its own prose ABOVE one of these. It is a template
108
+ * literal in adopter code, so nothing stops someone omitting it — which is why
109
+ * the door verifies its own served surface at boot rather than trusting that
110
+ * nobody did.
111
+ */
112
+ declare const FLOOR: Readonly<Record<"search" | "outline" | "read", string>>;
113
+ /** Every ksor tool is a read: no writes, safe to retry, closed world. */
114
+ declare const READ_ONLY: {
115
+ readonly readOnlyHint: true;
116
+ readonly destructiveHint: false;
117
+ readonly idempotentHint: true;
118
+ readonly openWorldHint: false;
119
+ };
120
+ declare const SEARCH_OUTPUT: StandardSchemaWithJSON;
121
+ declare const OUTLINE_OUTPUT: StandardSchemaWithJSON;
122
+ declare const READ_OUTPUT: StandardSchemaWithJSON;
123
+ interface SearchArgs {
124
+ readonly query: string;
125
+ readonly k: number;
126
+ }
127
+ declare function searchHandler(ctx: ServiceContext): (args: SearchArgs) => Promise<CallToolResult>;
128
+ interface OutlineArgs {
129
+ readonly node?: string | undefined;
130
+ readonly depth?: number | undefined;
131
+ readonly limit: number;
132
+ readonly offset?: number | undefined;
133
+ }
134
+ declare function outlineHandler(ctx: ServiceContext): (args: OutlineArgs) => Promise<CallToolResult>;
135
+ interface ReadArgs {
136
+ readonly slug: string;
137
+ readonly heading?: string | undefined;
138
+ readonly from_heading?: string | undefined;
139
+ readonly snapshot_token?: string | undefined;
140
+ readonly token_budget?: number | undefined;
141
+ }
142
+ declare function readHandler(ctx: ServiceContext): (args: ReadArgs) => Promise<CallToolResult>;
143
+ //#endregion
144
+ export { FLOOR, MAX_OUTLINE_LIMIT, MAX_SEARCH_K, McpServer$1 as McpServer, OUTLINE_OUTPUT, type OutlineArgs, READ_ONLY, READ_OUTPUT, type ReadArgs, SEARCH_OUTPUT, type SearchArgs, type ServiceContext, composeInstructions, outlineHandler, readHandler, recordIsUndescribed, searchHandler, z$1 as z };
@@ -0,0 +1,2 @@
1
+ import { L as z$1, O as readHandler, a as MAX_OUTLINE_LIMIT, d as READ_OUTPUT, f as SEARCH_OUTPUT, j as searchHandler, k as recordIsUndescribed, l as OUTLINE_OUTPUT, o as MAX_SEARCH_K, r as FLOOR, s as McpServer$1, u as READ_ONLY, w as outlineHandler, y as composeInstructions } from "./gateway-api-BF06IsJ--D-eI--yB.mjs";
2
+ export { FLOOR, MAX_OUTLINE_LIMIT, MAX_SEARCH_K, McpServer$1 as McpServer, OUTLINE_OUTPUT, READ_ONLY, READ_OUTPUT, SEARCH_OUTPUT, composeInstructions, outlineHandler, readHandler, recordIsUndescribed, searchHandler, z$1 as z };
package/docs/index.md CHANGED
@@ -32,6 +32,10 @@ instead of their training memory. The corpus grows with each implemented verb.
32
32
  export the manifest the site build reads), `ksor calibrate` (measure the
33
33
  abstention floor) and `ksor gc` (reap retired generations). Only `ksor dev` and `ksor build` remain designed, not
34
34
  implemented: each prints an honest notice and exits `2`.
35
+ - **[tool-surface.md](./tool-surface.md)** — shaping what agents see.
36
+ `system/gateways/content.ts` is emitted, adopter-owned and deletable; it
37
+ decides tool names, what the record says it covers, and how much of the
38
+ caller's context an answer costs. Includes the measurements.
35
39
  - **[deploying.md](./deploying.md)** — getting both surfaces onto a host. The
36
40
  scaffold emits a `Dockerfile` that names no vendor, and a `vercel.json` that
37
41
  points at it to put the site and the MCP door behind one domain. Includes
@@ -0,0 +1,133 @@
1
+ ---
2
+ title: The tool surface
3
+ status: draft
4
+ ---
5
+
6
+ # Shaping what agents see — `system/gateways/content.ts`
7
+
8
+ That file is your record's MCP registration: ordinary `registerTool` calls with
9
+ ordinary zod. It decides what your tools are called, what they say, what they
10
+ accept, and which of them exist. It is yours, and deleting it is supported —
11
+ without it the door serves the same defaults.
12
+
13
+ ## Why it is worth editing
14
+
15
+ An agent pays for this surface out of its context window, and it pays twice.
16
+ Every tool's name, description and input schema is resident for the whole
17
+ session; every answer spends more.
18
+
19
+ Measured against a live 81-document record (6,963 chunks), ~4 chars/token:
20
+
21
+ | | chars | ~tokens | |
22
+ | ------------------------------ | ------ | ------- | ------------------- |
23
+ | all three tool definitions | 11,960 | 2,990 | **always resident** |
24
+ | `search` alone | 5,383 | 1,346 | always resident |
25
+ | `outline` + `read` | 6,571 | 1,643 | always resident |
26
+ | one `search`, `k=10` (default) | 14,164 | 3,541 | per call |
27
+ | one `search`, `k=5` | 8,009 | 2,002 | per call |
28
+
29
+ An agent with five records attached carries ~15,000 tokens of definitions before
30
+ doing any work.
31
+
32
+ ## The three edits that pay
33
+
34
+ ### 1. Delete a tool nothing calls
35
+
36
+ The biggest win, and the easiest — delete its `registerTool` block. Measured
37
+ live: a registration keeping only a renamed `search` served **5,337** bytes
38
+ against the default's 11,960.
39
+
40
+ ### 2. Say what the record covers
41
+
42
+ The line that decides whether an agent asks _you_ rather than another record it
43
+ has open. Name the subject **and the boundary**:
44
+
45
+ ```ts
46
+ description: `Employee handbook: leave, benefits, conduct, expenses.
47
+ Not product documentation and not customer data.\n\n${FLOOR.search}`,
48
+ ```
49
+
50
+ Your prose goes **above** `FLOOR.search`, never instead of it — see below.
51
+
52
+ ### 3. Set `k`
53
+
54
+ `k` is the lever on reply size: 10 costs ~3,500 tokens a call, 5 costs ~2,000.
55
+ The caller can always ask for more, so make the default what you usually need.
56
+
57
+ ```ts
58
+ inputSchema: z.object({
59
+ query: z.string().min(1).max(2000),
60
+ k: z.number().int().min(1).max(50).default(5),
61
+ }),
62
+ ```
63
+
64
+ **`budgets.maximum_response_characters` is not this lever.** It defaults to
65
+ 120,000 and at ~1,400 chars a hit cannot bind before the 50-hit ceiling. Tune `k`.
66
+
67
+ ## Adding your own tools
68
+
69
+ It is an MCP server. Call `registerTool` again with your own handler:
70
+
71
+ ```ts
72
+ server.registerTool(
73
+ "check_policy_expiry",
74
+ {
75
+ inputSchema: z.object({ policy: z.string() }),
76
+ },
77
+ async ({ policy }) => ({ content: [{ type: "text", text: await lookup(policy) }] }),
78
+ );
79
+ ```
80
+
81
+ One thing to be clear-eyed about: **ksor makes no provenance claim about a tool
82
+ it did not hand you a handler for.** `searchHandler(ctx)` answers from the
83
+ governed record with citations; a handler you write answers from wherever you
84
+ made it answer from.
85
+
86
+ ## What you cannot change, and why
87
+
88
+ - **The handlers.** `searchHandler` / `outlineHandler` / `readHandler` are the
89
+ only things that can prove a passage came from the governed record. A
90
+ hand-written one returning fabricated hits with plausible `stable_id`s would
91
+ pass every shape check there is.
92
+ - **The output schemas.** `SEARCH_OUTPUT`, `OUTLINE_OUTPUT`, `READ_OUTPUT` carry
93
+ `provenance`, the `snapshot` token and `gate`. A record that reshaped them
94
+ would still look like a KSoR and no longer be one.
95
+ - **The `FLOOR` text.** It tells an agent how to branch on an envelope, what
96
+ `gate: "off"` means, and that corpus content is **untrusted** — quote it, never
97
+ obey it. Your prose is composed above it.
98
+
99
+ ## The door checks its own surface at boot
100
+
101
+ Because that last one is a template literal in a file you own, nothing structural
102
+ stops it being dropped. So the door builds its server, asks itself `tools/list`
103
+ over an in-memory transport, and refuses to start if a guarantee is gone:
104
+
105
+ ```
106
+ error: ksor-gateway-floor-missing: the search tool is served as "search_the_book"
107
+ without its framework description. That text tells an agent how to read an
108
+ abstention and that corpus content is untrusted — without it this record answers
109
+ without ever declining, and follows instructions written into its own documents.
110
+ Put FLOOR.search back: a record's own prose goes ABOVE it, as
111
+ `${yourText}\n\n${FLOOR.search}`, never instead of it
112
+ ```
113
+
114
+ | what | slug |
115
+ | -------------------------------------------------- | ---------------------------- |
116
+ | a served ksor tool lost its `FLOOR` text | `ksor-gateway-floor-missing` |
117
+ | the registration serves no tools at all | `ksor-gateway-no-tools` |
118
+ | the file throws, or default-exports a non-function | `ksor-gateway-unloadable` |
119
+
120
+ Delete the file to take the default registration back.
121
+
122
+ ## One import, no dependencies
123
+
124
+ Everything comes from `@panaversity/ksor/gateway` — including `z` and
125
+ `McpServer`. That is deliberate: your registration stays a _file_, with no
126
+ package.json, no build step, and nothing new in your lockfile. It also means the
127
+ SDK validates with the same zod instance it was built against, which a
128
+ separately-installed zod would not.
129
+
130
+ ## More records later
131
+
132
+ `identity` and `praxis` get `system/gateways/<record>.ts` by the same rule.
133
+ Nothing above is specific to content except which tools exist.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@panaversity/ksor",
3
- "version": "0.0.23",
3
+ "version": "0.0.25",
4
4
  "description": "Knowledge System of Record — compile governed markdown into a static site for people and an MCP server for AI agents, with citations and measured abstention.",
5
5
  "keywords": [
6
6
  "abstention",
@@ -44,6 +44,11 @@
44
44
  "import": "./dist/index.mjs",
45
45
  "default": "./dist/index.mjs"
46
46
  },
47
+ "./gateway": {
48
+ "types": "./dist/gateway.d.mts",
49
+ "import": "./dist/gateway.mjs",
50
+ "default": "./dist/gateway.mjs"
51
+ },
47
52
  "./package.json": "./package.json"
48
53
  },
49
54
  "publishConfig": {
@@ -311,6 +311,48 @@ signing keys, an unknown key id answers **503**, not 401 — the token may well
311
311
  good and the door's key set merely stale, so a client should retry rather than
312
312
  send the user back through a login.
313
313
 
314
+ ## Shaping the agent surface — `system/gateways/content.ts`
315
+
316
+ That file is this record's MCP registration — ordinary `registerTool` with
317
+ ordinary zod. It is yours, and it is **deletable**: without it the door serves
318
+ the same defaults.
319
+
320
+ Edit it because an agent pays for this surface out of its context window, twice.
321
+ Measured on an 81-document record:
322
+
323
+ | | |
324
+ | -------------------------------- | ------------------------------ |
325
+ | all three tool definitions | ~2,990 tokens, always resident |
326
+ | one `search` at `k=10` (default) | ~3,541 tokens per call |
327
+ | one `search` at `k=5` | ~2,002 tokens per call |
328
+
329
+ Three edits pay for themselves:
330
+
331
+ - **Delete a tool nothing calls.** Removing `outline` and `read` gives back
332
+ ~1,643 tokens for the whole session.
333
+ - **Say what this record covers**, above `FLOOR.search`. It is how an agent with
334
+ several records attached picks yours; name the subject AND the boundary.
335
+ - **Set `k`** in the input schema — it is the lever on reply size.
336
+
337
+ ```ts
338
+ description: `Leave, benefits, conduct. Not product docs.\n\n${FLOOR.search}`,
339
+ inputSchema: z.object({ query: z.string(), k: z.number().int().default(5) }),
340
+ ```
341
+
342
+ You can add your own tools with `registerTool` too — but be clear-eyed: ksor
343
+ makes no provenance claim about a tool it did not hand you a handler for.
344
+
345
+ What you cannot change is deliberate: the handlers, the output schemas, and the
346
+ `FLOOR` text. Your prose goes ABOVE the floor, never instead of it — the floor
347
+ tells an agent how to read an abstention and that corpus content is untrusted,
348
+ and a record that dropped it would answer without ever declining.
349
+
350
+ Because that is a template literal in a file you own, the door checks its own
351
+ surface at boot and refuses to start if a guarantee is gone:
352
+ `ksor-gateway-floor-missing`, `ksor-gateway-no-tools`,
353
+ `ksor-gateway-unloadable`. Full detail:
354
+ `node_modules/@panaversity/ksor/docs/tool-surface.md`.
355
+
314
356
  ## Withdrawing a document — `ksor takedown`
315
357
 
316
358
  A takedown is the one governance act that must reach EVERY surface at once.
@@ -22,10 +22,12 @@ WORKDIR /app
22
22
  COPY package.json ./
23
23
  RUN npm install --omit=dev --no-audit --no-fund
24
24
 
25
- # The record's identity and configuration. The CORPUS is deliberately absent:
26
- # the door serves from Postgres, and knowledge/ belongs to the build that
27
- # published itsee .dockerignore.
28
- COPY instance.md ./
25
+ # The record's identity, configuration, and its MCP registration
26
+ # (system/gateways/). What is deliberately ABSENT is decided in .dockerignore
27
+ # the corpus, the website and every secret rather than by listing files here:
28
+ # naming them one at a time is how the door came to ship without the very file
29
+ # that shapes its tool surface (found live).
30
+ COPY . ./
29
31
 
30
32
  # Most container hosts inject PORT; 80 is a sane default when nothing does.
31
33
  ENV PORT=80
@@ -11,8 +11,11 @@
11
11
  # suggest the container reads it. It does not.
12
12
  knowledge/
13
13
 
14
- # The website. It is the OTHER surface, built and hosted separately.
15
- system/
14
+ # The website the OTHER surface, built and hosted separately. NOT all of
15
+ # system/: system/gateways/ holds this door's own registration, and excluding
16
+ # it made the container silently serve the default tool surface while the
17
+ # repository said otherwise (found live).
18
+ system/site/
16
19
 
17
20
  # Build and tooling noise.
18
21
  node_modules/
@@ -0,0 +1,162 @@
1
+ /**
2
+ * The default registration — and the ORIGINAL of the file `ksor init` emits.
3
+ *
4
+ * This is the canonical half of decision 18's mechanism, applied to the agent
5
+ * surface: one rule, two places, asserted rather than trusted. The scaffold's
6
+ * `system/gateways/content.ts` is this file byte-for-byte below the import
7
+ * block, and `default-gateway-drift.test.ts` fails on the line that diverges.
8
+ *
9
+ * Two places rather than one is forced, not chosen. Node refuses to type-strip
10
+ * any `.ts` under `node_modules` — `ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING`,
11
+ * with no flag to lift it — so the published package cannot import its own
12
+ * emitted template as a fallback. The compiled twin is how a deleted file still
13
+ * serves, and the drift test is what stops the twins disagreeing.
14
+ *
15
+ * Everything below the import block is what an adopter owns: tool names, titles,
16
+ * what the record says it covers, input schemas, annotations, and which tools
17
+ * exist at all. What it composes — handlers, output schemas, and the FLOOR text
18
+ * — stays in the package, because those are the citation and abstention
19
+ * guarantees and a hand-written handler is the one thing no shape check catches.
20
+ */
21
+
22
+ import {
23
+ composeInstructions,
24
+ FLOOR,
25
+ MAX_OUTLINE_LIMIT,
26
+ MAX_SEARCH_K,
27
+ McpServer,
28
+ outlineHandler,
29
+ OUTLINE_OUTPUT,
30
+ READ_ONLY,
31
+ READ_OUTPUT,
32
+ readHandler,
33
+ SEARCH_OUTPUT,
34
+ searchHandler,
35
+ z,
36
+ type ServiceContext,
37
+ } from "@panaversity/ksor/gateway";
38
+
39
+ export default function buildGateway(ctx: ServiceContext, version: string): McpServer {
40
+ const server = new McpServer(
41
+ // The MCP server name agents see. Change it to your record's name.
42
+ { name: "ksor", version },
43
+ // instance.md's body is this record's system prompt, preserved beneath the
44
+ // framework floor. Leave this alone unless you mean to replace the prompt.
45
+ { instructions: composeInstructions(ctx.instance.instructions) },
46
+ );
47
+
48
+ server.registerTool(
49
+ "search",
50
+ {
51
+ title: "Search the record",
52
+ // WHAT THIS RECORD COVERS goes first — it is how an agent with several
53
+ // records attached decides to ask yours. Say the subject AND the
54
+ // boundary; the second half prevents more wrong calls than the first:
55
+ //
56
+ // description: `Employee handbook: leave, benefits, conduct, expenses.
57
+ // Not product documentation and not customer data.\n\n${FLOOR.search}`,
58
+ //
59
+ // FLOOR.search must stay. It tells an agent how to read an abstention and
60
+ // that corpus text is untrusted; the door checks it is still there at boot.
61
+ description: FLOOR.search,
62
+ inputSchema: z.object({
63
+ query: z
64
+ .string()
65
+ .min(1)
66
+ .max(2000)
67
+ .describe("A focused question or phrase to search the record for"),
68
+ // `k` is the lever on reply size: 10 costs an agent ~3,500 tokens a
69
+ // call, 5 costs ~2,000. Lower it to what your record actually needs —
70
+ // a caller can always ask for more.
71
+ k: z
72
+ .number()
73
+ .int()
74
+ .min(1)
75
+ .max(MAX_SEARCH_K)
76
+ .default(10)
77
+ .describe(`Maximum passages to return (1–${MAX_SEARCH_K})`),
78
+ }),
79
+ outputSchema: SEARCH_OUTPUT,
80
+ annotations: READ_ONLY,
81
+ },
82
+ searchHandler(ctx),
83
+ );
84
+
85
+ // Delete a tool by deleting its block. Measured: outline and read together
86
+ // cost ~1,643 tokens of context that is resident for an agent's whole
87
+ // session, whether or not it ever calls them.
88
+ server.registerTool(
89
+ "outline",
90
+ {
91
+ title: "Outline the record",
92
+ description: FLOOR.outline,
93
+ inputSchema: z.object({
94
+ node: z
95
+ .string()
96
+ .optional()
97
+ .describe("Slug or '/'-path to drill into; omit to browse the top level"),
98
+ depth: z.number().int().min(0).max(5).optional().describe("Extra levels below the anchor"),
99
+ limit: z
100
+ .number()
101
+ .int()
102
+ .min(1)
103
+ .max(MAX_OUTLINE_LIMIT)
104
+ .default(200)
105
+ .describe("Maximum rows in ONE page"),
106
+ offset: z
107
+ .number()
108
+ .int()
109
+ .min(0)
110
+ .optional()
111
+ .describe("Rows to skip — pass the previous response's next_offset to continue"),
112
+ }),
113
+ outputSchema: OUTLINE_OUTPUT,
114
+ annotations: READ_ONLY,
115
+ },
116
+ outlineHandler(ctx),
117
+ );
118
+
119
+ server.registerTool(
120
+ "read",
121
+ {
122
+ title: "Read a document",
123
+ description: FLOOR.read,
124
+ inputSchema: z.object({
125
+ slug: z.string().min(1).describe("The document's slug or '/'-qualified path (see outline)"),
126
+ heading: z
127
+ .string()
128
+ .optional()
129
+ .describe(
130
+ "Restrict to one section subtree: a full heading path, any prefix of one, or a " +
131
+ "section's last segment when it is unique in the document",
132
+ ),
133
+ from_heading: z
134
+ .string()
135
+ .optional()
136
+ .describe("Window cursor from a previous response's next"),
137
+ snapshot_token: z
138
+ .string()
139
+ .optional()
140
+ .describe(
141
+ 'The "token" string from a search response\'s "snapshot" object — not the object.',
142
+ ),
143
+ token_budget: z
144
+ .number()
145
+ .int()
146
+ .min(100)
147
+ .max(70000)
148
+ .optional()
149
+ .describe("Response size budget in tokens (default 70000)"),
150
+ }),
151
+ outputSchema: READ_OUTPUT,
152
+ annotations: READ_ONLY,
153
+ },
154
+ readHandler(ctx),
155
+ );
156
+
157
+ // Add your own tools here with ordinary registerTool + zod. They are yours;
158
+ // ksor makes no provenance claim about a tool it did not hand you a handler
159
+ // for, and the boot check only inspects the ones it did.
160
+
161
+ return server;
162
+ }