@panaversity/ksor 0.0.22 → 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 +71 -0
- package/dist/cli.mjs +259 -3277
- 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/deploying.md +233 -0
- package/docs/index.md +12 -0
- package/docs/ingesting.md +159 -0
- package/docs/tool-surface.md +133 -0
- package/package.json +6 -1
- package/templates/scaffold/AGENTS.md +71 -0
- package/templates/scaffold/Dockerfile +36 -0
- package/templates/scaffold/README.md +58 -1
- package/templates/scaffold/dockerignore +26 -0
- package/templates/scaffold/pnpm-workspace.yaml +5 -9
- package/templates/scaffold/system/gateways/content.ts +162 -0
- package/templates/scaffold/vercel.json +23 -5
|
@@ -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.
|
|
@@ -356,6 +398,35 @@ refuses rather than publish a document someone took down.
|
|
|
356
398
|
behind that audience's own access control, never on a public host.
|
|
357
399
|
Details in README → Deploying.
|
|
358
400
|
|
|
401
|
+
### The MCP door is a container
|
|
402
|
+
|
|
403
|
+
The other surface is a live process, so it ships as one. `Dockerfile` and
|
|
404
|
+
`.dockerignore` are yours, at the repo root, and they name no host:
|
|
405
|
+
|
|
406
|
+
```sh
|
|
407
|
+
docker build -t my-record .
|
|
408
|
+
docker run --rm -p 8080:80 --env-file .env my-record
|
|
409
|
+
```
|
|
410
|
+
|
|
411
|
+
That image runs on Cloud Run, Fly, Render, ECS, Kubernetes or a VPS unchanged.
|
|
412
|
+
`vercel.json` declares BOTH surfaces — a `site` service built from
|
|
413
|
+
`system/site/out/` and a `door` service pointing at that same `Dockerfile` —
|
|
414
|
+
with rewrites putting the door on `/mcp`, `/health`, `/ready` and the
|
|
415
|
+
`/.well-known/oauth-protected-resource` document, and the site on everything
|
|
416
|
+
else. Two rules if you edit it: the `/(.*)` catch-all must stay LAST, and do
|
|
417
|
+
not add a project-level `trailingSlash` — the site's Next config already sets
|
|
418
|
+
it, and at project level it 308-redirects `POST /mcp`, which breaks the door.
|
|
419
|
+
|
|
420
|
+
The image deliberately excludes `.env` (a baked DSN is published to anyone who
|
|
421
|
+
can pull the image), `knowledge/` (the door reads Postgres, never the folder)
|
|
422
|
+
and `system/` (the other surface).
|
|
423
|
+
|
|
424
|
+
**Deploying does not publish.** A container that ingested on boot would pay the
|
|
425
|
+
whole record's embedding cost on every cold start and need write credentials at
|
|
426
|
+
runtime. So `pnpm refresh` is a DEPLOY step you run — from your machine or from
|
|
427
|
+
CI — and a first deploy without it serves an empty record. Full walkthrough:
|
|
428
|
+
`node_modules/@panaversity/ksor/docs/deploying.md` and `…/docs/ingesting.md`.
|
|
429
|
+
|
|
359
430
|
## Writing knowledge
|
|
360
431
|
|
|
361
432
|
- One document per file under `knowledge/`; the path is the document's
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# The MCP door, as an ordinary container.
|
|
2
|
+
#
|
|
3
|
+
# Nothing here names a host. It installs the pinned `@panaversity/ksor` from
|
|
4
|
+
# package.json, listens on $PORT, and runs `ksor serve` — which is all Cloud Run,
|
|
5
|
+
# Fly, Render, ECS, Kubernetes or a plain VPS asks for. `vercel.json` points AT
|
|
6
|
+
# this file rather than replacing it, so the artifact stays portable and the host
|
|
7
|
+
# stays a choice.
|
|
8
|
+
#
|
|
9
|
+
# Build and run it anywhere:
|
|
10
|
+
# docker build -t my-record .
|
|
11
|
+
# docker run --rm -p 8080:80 --env-file .env my-record
|
|
12
|
+
#
|
|
13
|
+
# This image serves; it does not publish. `ksor ingest` is a write plane that
|
|
14
|
+
# runs from CI or your machine against the same database — see the deployment
|
|
15
|
+
# guide in node_modules/@panaversity/ksor/docs/deploying.md.
|
|
16
|
+
|
|
17
|
+
FROM node:24-alpine
|
|
18
|
+
|
|
19
|
+
WORKDIR /app
|
|
20
|
+
|
|
21
|
+
# Only the manifest first, so this layer caches until the ksor pin changes.
|
|
22
|
+
COPY package.json ./
|
|
23
|
+
RUN npm install --omit=dev --no-audit --no-fund
|
|
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 it — see .dockerignore.
|
|
28
|
+
COPY instance.md ./
|
|
29
|
+
|
|
30
|
+
# Most container hosts inject PORT; 80 is a sane default when nothing does.
|
|
31
|
+
ENV PORT=80
|
|
32
|
+
EXPOSE 80
|
|
33
|
+
|
|
34
|
+
# `ksor serve` refuses to boot unauthenticated on a public bind. That posture
|
|
35
|
+
# belongs to the record, not to the host, so it travels inside the image.
|
|
36
|
+
CMD ["node_modules/.bin/ksor", "serve", "--instance", "instance.md"]
|
|
@@ -60,6 +60,43 @@ unauthenticated: a local run declares `KSOR_AUTH_DISABLED=1` (already in
|
|
|
60
60
|
a public bind needs a configured SSO door instead. Any other operation is
|
|
61
61
|
`pnpm exec ksor <verb>`.
|
|
62
62
|
|
|
63
|
+
### Test the agent surface with an actual agent
|
|
64
|
+
|
|
65
|
+
The MCP door is meant to be read by agents, so check it with one rather than
|
|
66
|
+
with `curl`. With `pnpm serve` running, write `.mcp.json` at the repo root:
|
|
67
|
+
|
|
68
|
+
```json
|
|
69
|
+
{
|
|
70
|
+
"mcpServers": {
|
|
71
|
+
"test-record": {
|
|
72
|
+
"type": "http",
|
|
73
|
+
"url": "http://127.0.0.1:8080/mcp"
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Open a new session of your coding agent, confirm it lists the server, then ask
|
|
80
|
+
it three questions **in this order** — the order is the test:
|
|
81
|
+
|
|
82
|
+
1. Something the record covers, **phrased in words the document never uses**.
|
|
83
|
+
Retrieval is semantic, so this should still find it, and every answer should
|
|
84
|
+
arrive with a citation.
|
|
85
|
+
2. Something **adjacent but not covered** — your record's own subject area, a
|
|
86
|
+
question it genuinely does not answer. It should decline.
|
|
87
|
+
3. Something far outside the record. It should decline, and must not answer
|
|
88
|
+
from its own knowledge.
|
|
89
|
+
|
|
90
|
+
Question 2 is the one that matters. Anything can answer questions it has the
|
|
91
|
+
text for; refusing a plausible near-miss is the property that makes a system of
|
|
92
|
+
record worth trusting, and it is the one that breaks quietly.
|
|
93
|
+
|
|
94
|
+
**On a fresh record, 2 and 3 will not refuse — and that is honest, not broken.**
|
|
95
|
+
The abstention gate is off until you measure a floor for this corpus, which the
|
|
96
|
+
server says out loud at boot (`abstain OFF`) and in every search envelope
|
|
97
|
+
(`gate: "off"`). Run `pnpm exec ksor calibrate --instance instance.md` first if
|
|
98
|
+
you want to test refusal. Delete `.mcp.json`, or keep it — it holds no secret.
|
|
99
|
+
|
|
63
100
|
Then talk to your coding agent — `AGENTS.md` carries the working rules, and
|
|
64
101
|
the agent kit in `.agents/skills/` knows how to interview you
|
|
65
102
|
(`intake-interview`), convert your source material (`add-sources`), and keep
|
|
@@ -118,7 +155,9 @@ and anything that can serve files can serve it.
|
|
|
118
155
|
- **Vercel** — connect the repository (or run `vercel`); the shipped
|
|
119
156
|
`vercel.json` answers the setup interview: deploy from the repo root
|
|
120
157
|
(never pin `system/site` as the root directory — the record lives
|
|
121
|
-
outside it), build with `pnpm build`, serve `system/site/out/`.
|
|
158
|
+
outside it), build with `pnpm build`, serve `system/site/out/`. It also
|
|
159
|
+
declares the MCP **door** as a second service built from the shipped
|
|
160
|
+
`Dockerfile`, so `/mcp` and the site share one domain. If the
|
|
122
161
|
build image's pnpm predates the `packageManager` pin, set the
|
|
123
162
|
`ENABLE_EXPERIMENTAL_COREPACK=1` build environment variable.
|
|
124
163
|
**Once `instance.md` declares a `database:`, the BUILD needs the DSN too.**
|
|
@@ -143,6 +182,24 @@ writes "nothing denied" and exits 0.
|
|
|
143
182
|
- **Verify any deploy** the same way: the home page, one document page,
|
|
144
183
|
and `/llms.txt` all load; nothing else is required.
|
|
145
184
|
|
|
185
|
+
### The agent surface deploys separately
|
|
186
|
+
|
|
187
|
+
The site is files; the MCP door is a process. `Dockerfile` and `.dockerignore`
|
|
188
|
+
at the repo root build it, and they name no host — the same image runs on
|
|
189
|
+
Cloud Run, Fly, Render, ECS, Kubernetes or a VPS:
|
|
190
|
+
|
|
191
|
+
```sh
|
|
192
|
+
docker build -t my-record .
|
|
193
|
+
docker run --rm -p 8080:80 --env-file .env my-record
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
One thing surprises people: **deploying does not publish.** The door serves
|
|
197
|
+
whatever generation is already in the database, so a first deploy with no
|
|
198
|
+
`pnpm refresh` serves an empty record. Publishing is a step you run — from your
|
|
199
|
+
machine or from CI — and it is deliberately not something a booting container
|
|
200
|
+
does. The full walkthrough, including what a cold start costs and where ingest
|
|
201
|
+
belongs, is in `node_modules/@panaversity/ksor/docs/deploying.md`.
|
|
202
|
+
|
|
146
203
|
If `instance.md` declares `audiences:`, what you deploy is a **tier**.
|
|
147
204
|
Plain `pnpm build` always builds the public tier — safe for any host.
|
|
148
205
|
`KSOR_AUDIENCE=<audience> pnpm build` builds a wider tier for that
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# Keep the serving image to what serving needs.
|
|
2
|
+
|
|
3
|
+
# Secrets. The image takes its configuration from the environment at RUN time;
|
|
4
|
+
# baking a .env into a layer publishes it to anyone who can pull the image.
|
|
5
|
+
.env
|
|
6
|
+
.env.*
|
|
7
|
+
!.env.example
|
|
8
|
+
|
|
9
|
+
# The corpus. The door serves from Postgres — knowledge/ was published by
|
|
10
|
+
# `ksor ingest` before this container ever started, and copying it in would
|
|
11
|
+
# suggest the container reads it. It does not.
|
|
12
|
+
knowledge/
|
|
13
|
+
|
|
14
|
+
# The website. It is the OTHER surface, built and hosted separately.
|
|
15
|
+
system/
|
|
16
|
+
|
|
17
|
+
# Build and tooling noise.
|
|
18
|
+
node_modules/
|
|
19
|
+
.git/
|
|
20
|
+
.github/
|
|
21
|
+
.agents/
|
|
22
|
+
.claude/
|
|
23
|
+
.gemini/
|
|
24
|
+
*.log
|
|
25
|
+
.DS_Store
|
|
26
|
+
.ksor-denylist.json
|
|
@@ -19,17 +19,13 @@ minimumReleaseAgeExclude:
|
|
|
19
19
|
# Dependency install scripts are denied by default. Flip an entry to true
|
|
20
20
|
# only with a comment naming what breaks without it. pnpm 11 exits 1 on every
|
|
21
21
|
# install until each build script is explicitly decided (found live:
|
|
22
|
-
# fresh-scaffold pnpm dev, 2026-08-18).
|
|
23
|
-
# denied:
|
|
22
|
+
# fresh-scaffold pnpm dev, 2026-08-18). Both below are reviewed and stay denied:
|
|
24
23
|
# esbuild, sharp — prebuilt platform binaries ship as optionalDependencies,
|
|
25
24
|
# so their install scripts are download fallbacks the site never needs.
|
|
26
|
-
#
|
|
27
|
-
#
|
|
28
|
-
#
|
|
29
|
-
#
|
|
30
|
-
# workspace, 2026-08-19; serve works with both denied — verified live.)
|
|
25
|
+
#
|
|
26
|
+
# `@google/genai` and `protobufjs` were listed here too, as deps of the serve
|
|
27
|
+
# tool. The embedding provider now speaks the vendor's REST API directly, so
|
|
28
|
+
# neither package is installed at all and denying them described nothing.
|
|
31
29
|
allowBuilds:
|
|
32
30
|
esbuild: false
|
|
33
31
|
sharp: false
|
|
34
|
-
"@google/genai": false
|
|
35
|
-
protobufjs: false
|
|
@@ -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
|
+
}
|
|
@@ -1,8 +1,26 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://openapi.vercel.sh/vercel.json",
|
|
3
|
-
"
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
3
|
+
"services": {
|
|
4
|
+
"site": {
|
|
5
|
+
"root": ".",
|
|
6
|
+
"installCommand": "pnpm install --no-frozen-lockfile",
|
|
7
|
+
"buildCommand": "pnpm build",
|
|
8
|
+
"outputDirectory": "system/site/out"
|
|
9
|
+
},
|
|
10
|
+
"door": {
|
|
11
|
+
"root": ".",
|
|
12
|
+
"runtime": "container",
|
|
13
|
+
"entrypoint": "Dockerfile"
|
|
14
|
+
}
|
|
15
|
+
},
|
|
16
|
+
"rewrites": [
|
|
17
|
+
{ "source": "/mcp(.*)", "destination": { "service": "door" } },
|
|
18
|
+
{
|
|
19
|
+
"source": "/.well-known/oauth-protected-resource(.*)",
|
|
20
|
+
"destination": { "service": "door" }
|
|
21
|
+
},
|
|
22
|
+
{ "source": "/health", "destination": { "service": "door" } },
|
|
23
|
+
{ "source": "/ready", "destination": { "service": "door" } },
|
|
24
|
+
{ "source": "/(.*)", "destination": { "service": "site" } }
|
|
25
|
+
]
|
|
8
26
|
}
|