@panaversity/ksor 0.0.11 → 0.0.12
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 +107 -0
- package/dist/cli.mjs +83 -17
- package/package.json +1 -1
- package/templates/scaffold/AGENTS.md +64 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,112 @@
|
|
|
1
1
|
# @panaversity/ksor
|
|
2
2
|
|
|
3
|
+
## 0.0.12
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 36e4a4c: The scaffold documents what a CLIENT has to do to reach a public MCP door
|
|
8
|
+
|
|
9
|
+
`ksor serve` implements the OAuth Resource Server handshake — an
|
|
10
|
+
unauthenticated request gets a 401 carrying
|
|
11
|
+
`WWW-Authenticate: Bearer resource_metadata="…"`, and that document names the
|
|
12
|
+
record's resource identifier and its authorization server, so a client discovers
|
|
13
|
+
where to authenticate instead of being told. None of it was written down
|
|
14
|
+
anywhere an adopter or their agent reads. The operator half was documented (the
|
|
15
|
+
three environment variables); the half their agents actually execute was not.
|
|
16
|
+
|
|
17
|
+
The scaffold's `AGENTS.md` now walks the three steps, and names the failure that
|
|
18
|
+
goes wrong quietly: a token minted for a different audience is a perfectly valid
|
|
19
|
+
token, and this door rejects it, so `aud` against `KSOR_JWT_ALLOWED_AUDIENCES` is
|
|
20
|
+
the first thing to compare when a client authenticates fine and still gets 401.
|
|
21
|
+
It also records the two behaviours a client author has to know and could not have
|
|
22
|
+
guessed — RS256 only, no opaque-token introspection, and an unknown key id
|
|
23
|
+
answering 503 rather than 401, because during a key rotation the token is
|
|
24
|
+
probably good and retrying beats sending the user back through a login.
|
|
25
|
+
|
|
26
|
+
This closes one of the three items named in issue #26; the worked provider
|
|
27
|
+
recipes and the introspection/rotation policy remain open there.
|
|
28
|
+
|
|
29
|
+
- 125970c: Every 401 from the MCP door carries its `WWW-Authenticate` challenge, not just the first
|
|
30
|
+
|
|
31
|
+
Only the missing-token branch emitted `WWW-Authenticate: Bearer
|
|
32
|
+
resource_metadata="…"`. A token that failed verification — expired, wrong
|
|
33
|
+
audience, no subject, bad signature — came back as a bare 401. That is the most
|
|
34
|
+
common 401 a real client will ever see, because tokens expire mid-conversation,
|
|
35
|
+
and it left the client with no pointer back to the resource-metadata document:
|
|
36
|
+
it could not re-discover the authorization server it had just been talking to.
|
|
37
|
+
Only a caller that had never sent a token was told where to go.
|
|
38
|
+
|
|
39
|
+
The MCP authorization spec requires `WWW-Authenticate` on a 401 without
|
|
40
|
+
qualification. Every 401 now carries it, with RFC 6750's `error="invalid_token"`
|
|
41
|
+
so a client refreshes rather than retrying the dead token.
|
|
42
|
+
|
|
43
|
+
A **503** stays deliberately unchallenged: an unreachable key set is our outage,
|
|
44
|
+
not the token's fault, and challenging there would send a user whose token is
|
|
45
|
+
perfectly good back through a login over a key-fetch failure.
|
|
46
|
+
|
|
47
|
+
Found by adversarially checking the release that documented this door. The
|
|
48
|
+
adversarial auth suite missed it by asserting the STATUS of each rejection and
|
|
49
|
+
never the header — it now sweeps every 401-producing token and asserts the
|
|
50
|
+
challenge on each, with the 503 as the negative control.
|
|
51
|
+
|
|
52
|
+
- 0a94e31: A 503 refusal no longer puts the database host and user on the wire
|
|
53
|
+
|
|
54
|
+
When the deferred boot checks fail, `/mcp` refuses with the thrown error's
|
|
55
|
+
message in full under `data.detail`. For the three authored failures that is the
|
|
56
|
+
point — a too-old schema, a governance violation and a text-search mismatch each
|
|
57
|
+
carry a multi-line remedy the operator has to act on. But the catch treated every
|
|
58
|
+
error alike, and `pg` writes the host, its resolved address, the port and the
|
|
59
|
+
database user into its connection and authentication failures. Those went out
|
|
60
|
+
verbatim to any caller who could reach the door.
|
|
61
|
+
|
|
62
|
+
What may leave is now decided in one place and by TYPE, not by inspecting
|
|
63
|
+
message text: a class we wrote is a class whose words we control. A driver error
|
|
64
|
+
is refused with its class named and its text withheld, and the caller is told
|
|
65
|
+
which kind of failure it is — infrastructure, not their request.
|
|
66
|
+
|
|
67
|
+
The full text still reaches the operator, deliberately: the refusal says the
|
|
68
|
+
reason is in the server's logs, and the deferred-boot line recorded only the
|
|
69
|
+
error's NAME, so before this the real message existed nowhere. That is also why
|
|
70
|
+
the boot checks are not sanitised at their source — reducing a driver error to a
|
|
71
|
+
class name early would destroy the one copy anyone can act on.
|
|
72
|
+
|
|
73
|
+
**The test that covered this was holding it in place.** It asserted that
|
|
74
|
+
`http.ts` contains the literal string `data: { detail: message }` — so the leak
|
|
75
|
+
was pinned by an assertion with reasoning attached. Grepping source is the right
|
|
76
|
+
instrument for "does this check run before dispatch", because position is a
|
|
77
|
+
property of source, and the wrong one for "what does the response contain".
|
|
78
|
+
Response contents are now asserted against real bodies, including a `pg`-shaped
|
|
79
|
+
connection failure whose host, address, port and user must all be absent.
|
|
80
|
+
|
|
81
|
+
Verified live: a gateway pointed at an unreachable database answers
|
|
82
|
+
`the content store is unavailable (Error)` with no host, port, user or database
|
|
83
|
+
name anywhere in the body, while the server log carries
|
|
84
|
+
`connect ECONNREFUSED 127.0.0.1:59999` in full.
|
|
85
|
+
|
|
86
|
+
- 1dd6211: The dimension ceiling says which shape it applies to, instead of blaming pgvector
|
|
87
|
+
|
|
88
|
+
`ksor schema` refuses an embedding dimension above 2000 with
|
|
89
|
+
"(pgvector vector + HNSW ceiling)". The refusal is right and the reason was
|
|
90
|
+
wrong: pgvector indexes a `vector` to 2000, but a **`halfvec` to 4000**, via an
|
|
91
|
+
expression index on the cast — verified live against a real database, where
|
|
92
|
+
`hnsw ((embedding::halfvec(3072)) halfvec_cosine_ops)` plans an Index Scan.
|
|
93
|
+
|
|
94
|
+
The old wording read as pgvector's own limit, so an adopter whose model emits
|
|
95
|
+
more than 2000 dimensions could conclude it was unusable here, over a wall that
|
|
96
|
+
is not one. The message now names the shape the ceiling belongs to — this schema
|
|
97
|
+
declares `VECTOR(dim)` columns and indexes one directly — and the constant
|
|
98
|
+
carries why raising it is a decision rather than an edit: every query site would
|
|
99
|
+
have to use the same cast as the index or fall silently back to a sequential
|
|
100
|
+
scan, and the halfvec arm's float16 rounding lands on the score the abstention
|
|
101
|
+
gate reads.
|
|
102
|
+
|
|
103
|
+
The same claim is corrected in the scaffold's `AGENTS.md`, which gains the reason
|
|
104
|
+
`dim: 1536` is the shipped default: `gemini-embedding-001` emits 3072 and ksor
|
|
105
|
+
asks it for 1536, which per Google's published MTEB table costs nothing
|
|
106
|
+
measurable — 1536 scores 68.17 against 2048's 68.16.
|
|
107
|
+
|
|
108
|
+
The 2000 refusal is unchanged. Issue #49 records the decision it now points at.
|
|
109
|
+
|
|
3
110
|
## 0.0.11
|
|
4
111
|
|
|
5
112
|
### Patch Changes
|
package/dist/cli.mjs
CHANGED
|
@@ -16,7 +16,7 @@ import { bodyLimit } from "hono/body-limit";
|
|
|
16
16
|
import { execFileSync, spawnSync } from "node:child_process";
|
|
17
17
|
import { parseArgs } from "node:util";
|
|
18
18
|
import { readFile, readdir, stat } from "node:fs/promises";
|
|
19
|
-
//#region ../content-gateway/dist/main-
|
|
19
|
+
//#region ../content-gateway/dist/main-CqOtG-q1.mjs
|
|
20
20
|
/**
|
|
21
21
|
* A connection could not be ESTABLISHED in time — retryable.
|
|
22
22
|
*
|
|
@@ -448,6 +448,8 @@ const CHUNK_POLICY$1 = "heading-aware-1500-content-only-v5";
|
|
|
448
448
|
* The Markdown BODY below the frontmatter is the authored agent-surface
|
|
449
449
|
* instructions — byte-preserved, stripped only at the edges.
|
|
450
450
|
*/
|
|
451
|
+
/** Mirrors `schema.ts`'s ceiling, so a bad `dim:` is refused when instance.md is
|
|
452
|
+
* PARSED rather than when the DDL is rendered. The why lives there. */
|
|
451
453
|
const EMBED_DIM_MAX$1$1 = 2e3;
|
|
452
454
|
const SUPPORTED_FORMATS$1$1 = [1];
|
|
453
455
|
var InstanceParseError$1 = class extends Error {
|
|
@@ -541,7 +543,7 @@ const groupSchemas$1 = {
|
|
|
541
543
|
embedding: z.object({
|
|
542
544
|
provider: z.string().min(1).default("gemini"),
|
|
543
545
|
model: z.string().min(1).default(EMBED_MODEL$1),
|
|
544
|
-
dim: z.coerce.number().int().min(1).max(EMBED_DIM_MAX$1$1).default(EMBED_DIM$1)
|
|
546
|
+
dim: z.coerce.number().int().min(1).max(EMBED_DIM_MAX$1$1, { error: `at most ${EMBED_DIM_MAX$1$1}: this schema declares VECTOR columns and indexes one directly, and pgvector's HNSW takes a vector to ${EMBED_DIM_MAX$1$1}` }).default(EMBED_DIM$1)
|
|
545
547
|
}),
|
|
546
548
|
retrieval: z.object({
|
|
547
549
|
/**
|
|
@@ -3723,6 +3725,54 @@ async function compose(instancePath, version) {
|
|
|
3723
3725
|
};
|
|
3724
3726
|
}
|
|
3725
3727
|
/**
|
|
3728
|
+
* The body of a deferred-boot refusal, and the one decision inside it: whose
|
|
3729
|
+
* message may go on the wire.
|
|
3730
|
+
*
|
|
3731
|
+
* Three AUTHORED failures carry a remedy written for the operator —
|
|
3732
|
+
* `SchemaVersionError`, `GovernanceGateError`, `TextSearchConfigMismatch`. Their
|
|
3733
|
+
* whole multi-line message is the point; a caller that receives only the first
|
|
3734
|
+
* line has been told a problem exists and not how to end it.
|
|
3735
|
+
*
|
|
3736
|
+
* Everything else reaching this catch is infrastructure — most of it straight
|
|
3737
|
+
* from `pg`, whose connection and authentication failures name the host, the
|
|
3738
|
+
* resolved address, the port and the database user. Those went out verbatim
|
|
3739
|
+
* under `data.detail`, because the catch treated every error alike.
|
|
3740
|
+
*
|
|
3741
|
+
* So the split is by TYPE, not by message inspection: a class we wrote is a
|
|
3742
|
+
* class whose text we control. A driver error is refused with its class named
|
|
3743
|
+
* and its text withheld — the operator finds the real message in the server's
|
|
3744
|
+
* own logs, which is where an infrastructure fault belongs.
|
|
3745
|
+
*/
|
|
3746
|
+
/**
|
|
3747
|
+
* Did WE write this error's text?
|
|
3748
|
+
*
|
|
3749
|
+
* Named classes only. Matching on message prose would put the decision back
|
|
3750
|
+
* inside the strings it is meant to police, and a reworded driver error would
|
|
3751
|
+
* quietly re-open the leak.
|
|
3752
|
+
*/
|
|
3753
|
+
function isAuthored(error) {
|
|
3754
|
+
return error instanceof SchemaVersionError || error instanceof GovernanceGateError$1 || error instanceof TextSearchConfigMismatch;
|
|
3755
|
+
}
|
|
3756
|
+
function refusalBody(error) {
|
|
3757
|
+
if (isAuthored(error)) return {
|
|
3758
|
+
jsonrpc: "2.0",
|
|
3759
|
+
error: {
|
|
3760
|
+
code: -32001,
|
|
3761
|
+
message: `this record cannot be served: ${error.message.split("\n")[0] ?? ""}`,
|
|
3762
|
+
data: { detail: error.message }
|
|
3763
|
+
},
|
|
3764
|
+
id: null
|
|
3765
|
+
};
|
|
3766
|
+
return {
|
|
3767
|
+
jsonrpc: "2.0",
|
|
3768
|
+
error: {
|
|
3769
|
+
code: -32001,
|
|
3770
|
+
message: `this record cannot be served: the content store is unavailable (${error instanceof Error ? error.name : "Error"}). The reason is in this server's logs; it is withheld here because a driver error names the database host and user.`
|
|
3771
|
+
},
|
|
3772
|
+
id: null
|
|
3773
|
+
};
|
|
3774
|
+
}
|
|
3775
|
+
/**
|
|
3726
3776
|
* The MCP door: the SDK v2 HTTP entry (Request → Response, stateless)
|
|
3727
3777
|
* behind Hono, serving the 2026-07-28 revision with 2025-era clients still
|
|
3728
3778
|
* answered through the stateless fallback. Modern exchanges are buffered JSON;
|
|
@@ -3886,16 +3936,8 @@ async function runHttp(composition) {
|
|
|
3886
3936
|
if (verifyBoot !== null) try {
|
|
3887
3937
|
await verifyBoot();
|
|
3888
3938
|
} catch (error) {
|
|
3889
|
-
|
|
3890
|
-
return new Response(JSON.stringify({
|
|
3891
|
-
jsonrpc: "2.0",
|
|
3892
|
-
error: {
|
|
3893
|
-
code: -32001,
|
|
3894
|
-
message: `this record cannot be served: ${message.split("\n")[0]}`,
|
|
3895
|
-
data: { detail: message }
|
|
3896
|
-
},
|
|
3897
|
-
id: null
|
|
3898
|
-
}), {
|
|
3939
|
+
console.error(`refusing requests — boot checks failing: ${error instanceof Error ? error.stack ?? error.message : String(error)}`);
|
|
3940
|
+
return new Response(JSON.stringify(refusalBody(error)), {
|
|
3899
3941
|
status: 503,
|
|
3900
3942
|
headers: { "content-type": "application/json" }
|
|
3901
3943
|
});
|
|
@@ -3939,7 +3981,7 @@ async function runHttp(composition) {
|
|
|
3939
3981
|
identity = await auth.verify(token);
|
|
3940
3982
|
} catch (error) {
|
|
3941
3983
|
const transient = error instanceof TokenVerifyError && error.transient;
|
|
3942
|
-
return c.json({ error: transient ? "token verification temporarily unavailable" : "invalid token" }, transient ? 503 : 401);
|
|
3984
|
+
return c.json({ error: transient ? "token verification temporarily unavailable" : "invalid token" }, transient ? 503 : 401, transient ? {} : { "www-authenticate": `Bearer error="invalid_token", resource_metadata="${resourceMetadataUrl}"` });
|
|
3943
3985
|
}
|
|
3944
3986
|
bearer = token;
|
|
3945
3987
|
}
|
|
@@ -4389,7 +4431,7 @@ async function withPgRetry(op, options = {}) {
|
|
|
4389
4431
|
throw lastError;
|
|
4390
4432
|
}
|
|
4391
4433
|
//#endregion
|
|
4392
|
-
//#region ../content/dist/commands-
|
|
4434
|
+
//#region ../content/dist/commands-Cysnkk_R.mjs
|
|
4393
4435
|
/**
|
|
4394
4436
|
* EVAL-LOCKED constants, quarried verbatim from the oracle
|
|
4395
4437
|
* (sor-agentfactory @ b554f91, config.py) — changing any of these is a
|
|
@@ -4425,6 +4467,8 @@ const HARD_MAX_CHARS = 4e3;
|
|
|
4425
4467
|
* The Markdown BODY below the frontmatter is the authored agent-surface
|
|
4426
4468
|
* instructions — byte-preserved, stripped only at the edges.
|
|
4427
4469
|
*/
|
|
4470
|
+
/** Mirrors `schema.ts`'s ceiling, so a bad `dim:` is refused when instance.md is
|
|
4471
|
+
* PARSED rather than when the DDL is rendered. The why lives there. */
|
|
4428
4472
|
const EMBED_DIM_MAX$1 = 2e3;
|
|
4429
4473
|
const SUPPORTED_FORMATS$1 = [1];
|
|
4430
4474
|
var InstanceParseError = class extends Error {
|
|
@@ -4518,7 +4562,7 @@ const groupSchemas = {
|
|
|
4518
4562
|
embedding: z.object({
|
|
4519
4563
|
provider: z.string().min(1).default("gemini"),
|
|
4520
4564
|
model: z.string().min(1).default(EMBED_MODEL),
|
|
4521
|
-
dim: z.coerce.number().int().min(1).max(EMBED_DIM_MAX$1).default(EMBED_DIM)
|
|
4565
|
+
dim: z.coerce.number().int().min(1).max(EMBED_DIM_MAX$1, { error: `at most ${EMBED_DIM_MAX$1}: this schema declares VECTOR columns and indexes one directly, and pgvector's HNSW takes a vector to ${EMBED_DIM_MAX$1}` }).default(EMBED_DIM)
|
|
4522
4566
|
}),
|
|
4523
4567
|
retrieval: z.object({
|
|
4524
4568
|
/**
|
|
@@ -4942,7 +4986,29 @@ async function runIngest(pool, tenantId, op) {
|
|
|
4942
4986
|
* fresh DDL is rendered from the instance that will fill it, never
|
|
4943
4987
|
* hand-edited.
|
|
4944
4988
|
*/
|
|
4945
|
-
/**
|
|
4989
|
+
/**
|
|
4990
|
+
* The largest embedding dimension this schema will render.
|
|
4991
|
+
*
|
|
4992
|
+
* It is the ceiling for the shape we USE, not pgvector's ceiling: `schema.sql`
|
|
4993
|
+
* declares two `VECTOR(dim)` columns and indexes one of them directly, and
|
|
4994
|
+
* pgvector's HNSW and IVFFlat take a `vector` to 2000. They take a `halfvec` to
|
|
4995
|
+
* **4000**, reachable by indexing an expression — `hnsw ((embedding::halfvec(N))
|
|
4996
|
+
* halfvec_cosine_ops)` — which we do not do, so 2000 binds here.
|
|
4997
|
+
*
|
|
4998
|
+
* Said precisely because the old wording ("pgvector vector + HNSW ceiling")
|
|
4999
|
+
* read as pgvector's own limit and sent a reader off to change providers over a
|
|
5000
|
+
* wall that is not one (verified live against a real database, 2026-08-21:
|
|
5001
|
+
* a halfvec(3072) expression index plans an Index Scan).
|
|
5002
|
+
*
|
|
5003
|
+
* Raising it is a decision, not a constant: every query site would have to use
|
|
5004
|
+
* the same cast as the index or fall silently back to a sequential scan, and
|
|
5005
|
+
* the halfvec arm's float16 rounding lands on the score the abstention gate
|
|
5006
|
+
* reads. Recorded in issue #49, along with the evidence for staying at 1536 —
|
|
5007
|
+
* Google's published MTEB table runs 128..2048 and is FLAT at the top of that
|
|
5008
|
+
* range (1536 scores 68.17, 2048 scores 68.16), so there is no gradient to
|
|
5009
|
+
* climb toward the ceiling. It carries no 3072 row, so the cost of the
|
|
5010
|
+
* truncation itself is unpublished; do not infer one.
|
|
5011
|
+
*/
|
|
4946
5012
|
const EMBED_DIM_MAX = 2e3;
|
|
4947
5013
|
/** The schema version schema.sql declares — parsed from the DDL so code and
|
|
4948
5014
|
* the applied database share ONE source (a drift test pins the coupling). */
|
|
@@ -4989,7 +5055,7 @@ function verifyTemplate(text, dim) {
|
|
|
4989
5055
|
}
|
|
4990
5056
|
/** The pure core: render the given template text at the given dimension. */
|
|
4991
5057
|
function renderSchemaText(text, dim, textSearchConfig = SHIPPED_TS_CONFIG) {
|
|
4992
|
-
if (!Number.isInteger(dim) || dim < 1 || dim > 2e3) throw new Error(`dim must be an integer in 1..${EMBED_DIM_MAX}
|
|
5058
|
+
if (!Number.isInteger(dim) || dim < 1 || dim > 2e3) throw new Error(`dim must be an integer in 1..${EMBED_DIM_MAX} — this schema indexes a vector column directly, and pgvector's HNSW takes a vector to 2000 — got ${JSON.stringify(dim)}`);
|
|
4993
5059
|
verifyTemplate(text, EMBED_DIM);
|
|
4994
5060
|
const withTs = renderTsConfig(text, textSearchConfig);
|
|
4995
5061
|
if (dim === 1536) return withTs;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@panaversity/ksor",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.12",
|
|
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",
|
|
@@ -65,8 +65,13 @@ Stand it up in this order (each step's errors explain how to fix themselves):
|
|
|
65
65
|
`provider: gemini`, `model: gemini-embedding-001`, `dim: 1536`; write it out
|
|
66
66
|
only to pin the space explicitly or to change it — and note that model and
|
|
67
67
|
dim are the PERSISTED identity of the embedding space, so changing either
|
|
68
|
-
later means re-embedding the whole corpus. Keep `dim` at or below 2000
|
|
69
|
-
|
|
68
|
+
later means re-embedding the whole corpus. Keep `dim` at or below 2000 — the
|
|
69
|
+
schema indexes a `vector` column directly and pgvector's HNSW takes a
|
|
70
|
+
`vector` to 2000. `gemini-embedding-001` emits 3072 by default, so ksor asks
|
|
71
|
+
it for 1536. Google's published MTEB table runs 128–2048 and is flat at the
|
|
72
|
+
top of it — 1536 scores 68.17 against 2048's 68.16 — so there is no gradient
|
|
73
|
+
to climb toward the ceiling; going the other way, 768 costs 0.18 if you want
|
|
74
|
+
the storage back.
|
|
70
75
|
|
|
71
76
|
Leave `retrieval:` out for now — the gate is off and the server says so.
|
|
72
77
|
Turning it on is step 4, AFTER the record is serving.
|
|
@@ -227,7 +232,7 @@ bind, set `KSOR_ALLOWED_HOSTS` / `KSOR_ALLOWED_ORIGINS`; on more than one
|
|
|
227
232
|
replica, set a shared `KSOR_SNAPSHOT_KEYS` (unset ⇒ a per-process key, so a
|
|
228
233
|
search token minted by one replica fails on another).
|
|
229
234
|
|
|
230
|
-
|
|
235
|
+
Three things worth being deliberate about:
|
|
231
236
|
|
|
232
237
|
- **`KSOR_ALLOW_PUBLIC_UNAUTHENTICATED=1` serves your whole record to anyone
|
|
233
238
|
who can reach the port.** It exists for deployments fronted by your own
|
|
@@ -244,6 +249,62 @@ Two things worth being deliberate about:
|
|
|
244
249
|
`KSOR_JWKS_URL` only to override that, or when your SSO publishes no metadata
|
|
245
250
|
at all.
|
|
246
251
|
|
|
252
|
+
### What a CLIENT has to do
|
|
253
|
+
|
|
254
|
+
Once the SSO door is configured (the three variables above), the server is an
|
|
255
|
+
OAuth **Resource Server**, which means a client is not told the authorization
|
|
256
|
+
server — it discovers it. Nothing here needs configuring beyond those variables;
|
|
257
|
+
this is what your agents will experience, and what to check when one cannot
|
|
258
|
+
connect. With `KSOR_AUTH_DISABLED=1` — the local default `.env.example` ships —
|
|
259
|
+
none of it applies: there is no challenge and the metadata document answers 404,
|
|
260
|
+
because there is no authorization server to point at.
|
|
261
|
+
|
|
262
|
+
1. The client calls `POST /mcp` with no token and gets **401** carrying
|
|
263
|
+
|
|
264
|
+
```
|
|
265
|
+
WWW-Authenticate: Bearer resource_metadata="https://<your-host>/.well-known/oauth-protected-resource/mcp"
|
|
266
|
+
```
|
|
267
|
+
|
|
268
|
+
That header is the whole handshake: it names a DOCUMENT, not the resource.
|
|
269
|
+
|
|
270
|
+
2. The client fetches that document and finds the record's resource identifier
|
|
271
|
+
and its authorization server:
|
|
272
|
+
|
|
273
|
+
```json
|
|
274
|
+
{
|
|
275
|
+
"resource": "https://<your-host>/mcp",
|
|
276
|
+
"authorization_servers": ["https://your-sso.example.com"]
|
|
277
|
+
}
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
Those two values are `KSOR_MCP_RESOURCE_URL` and `KSOR_SSO_URL`.
|
|
281
|
+
|
|
282
|
+
3. The client gets a token from that authorization server, asking for THIS
|
|
283
|
+
record as the resource (RFC 8707: `resource=https://<your-host>/mcp`), and
|
|
284
|
+
sends it as `Authorization: Bearer <token>`.
|
|
285
|
+
|
|
286
|
+
Every 401 carries that same header, not just the first one — including the one
|
|
287
|
+
your clients will hit most often, a token that expired mid-conversation. It
|
|
288
|
+
arrives as `Bearer error="invalid_token", resource_metadata="…"`, so a client
|
|
289
|
+
knows to refresh rather than to retry the dead token. A **503** is deliberately
|
|
290
|
+
_not_ challenged: an unreachable key set is our outage, not your token's fault,
|
|
291
|
+
and telling a client to re-authenticate over it would send a perfectly good user
|
|
292
|
+
back through a login.
|
|
293
|
+
|
|
294
|
+
The one thing that goes wrong here goes wrong quietly: a token minted for a
|
|
295
|
+
different audience is a perfectly valid token, and this door rejects it. The
|
|
296
|
+
`aud` claim must match one of `KSOR_JWT_ALLOWED_AUDIENCES` — normally the same
|
|
297
|
+
value as `KSOR_MCP_RESOURCE_URL` — because a bearer accepted for any audience is
|
|
298
|
+
a bearer stolen from one service and replayed against this one. If a client
|
|
299
|
+
authenticates fine and still gets 401, compare its token's `aud` against that
|
|
300
|
+
list before looking anywhere else.
|
|
301
|
+
|
|
302
|
+
Tokens must be signed **RS256**; nothing else is accepted, and opaque tokens are
|
|
303
|
+
not supported (there is no introspection call). When your SSO rotates its
|
|
304
|
+
signing keys, an unknown key id answers **503**, not 401 — the token may well be
|
|
305
|
+
good and the door's key set merely stale, so a client should retry rather than
|
|
306
|
+
send the user back through a login.
|
|
307
|
+
|
|
247
308
|
## Withdrawing a document — `ksor takedown`
|
|
248
309
|
|
|
249
310
|
A takedown is the one governance act that must reach EVERY surface at once.
|