@panaversity/ksor 0.0.23 → 0.0.24
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +36 -0
- package/dist/cli.mjs +254 -3276
- package/dist/gateway-api-BF06IsJ--D-eI--yB.mjs +3225 -0
- package/dist/gateway.d.mts +144 -0
- package/dist/gateway.mjs +2 -0
- package/docs/index.md +4 -0
- package/docs/tool-surface.md +133 -0
- package/package.json +6 -1
- package/templates/scaffold/AGENTS.md +42 -0
- package/templates/scaffold/system/gateways/content.ts +162 -0
|
@@ -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 };
|
package/dist/gateway.mjs
ADDED
|
@@ -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.
|
|
3
|
+
"version": "0.0.24",
|
|
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.
|
|
@@ -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
|
+
}
|