Skip to content

okfy-mcp over HTTP: one shared token, or identities from an OpenID issuer - #7

Closed
theaspirational wants to merge 18 commits into
vsov:mainfrom
theaspirational:pr/mcp-http-oidc
Closed

theaspirational wants to merge 18 commits into
vsov:mainfrom
theaspirational:pr/mcp-http-oidc

Conversation

@theaspirational

@theaspirational theaspirational commented Oct 15, 2026 •

Copy link
Copy Markdown
Contributor

Stacked on the direct-write lane PR (and through it on #5 and #4): the lane is what gives a verified identity something to write. The diff shows the lower commits until they merge; merge in order and nothing conflicts.

stdio is one process per agent session on one machine. okfy-mcp serve --http is one long-running process on a host, serving the same seven tools to every client, each seeing the others' writes on its next call. Two access models, eight commits in the order they were needed.

One shared token

  • The token is read from a file, never a flag; missing, unreadable or short refuses to start (E_MCP_HTTP_TOKEN). A request without the header is 401 with the documented envelope (E_MCP_UNAUTHORIZED) before the MCP layer sees it.
  • Stateless, JSON responses, no Session trace: one process serves everyone, so observed is absent rather than attesting one client's reads to another's write.
  • Plain HTTP on loopback by default. TLS is a reverse proxy's job; --public-url names the host that proxy presents, so the SDK's DNS-rebinding guard accepts it.

Identities from an OpenID issuer

  • --oidc-issuer <url> --oidc-audience <client id>: every request carries a JWT from that issuer, checked against its published keys (RS256, iss, aud, exp). Discovery is read once at start, so a wrong issuer refuses to start (E_MCP_OIDC_ISSUER) instead of failing every request. The SDK's middleware answers 401/403 and the protected-resource metadata is served by okfy-mcp itself (okfy:read, okfy:write, offline_access advertised, okfy:read alone required), so an MCP client can find where to log in and ask for the right scopes.
  • Under a token, okfy_write and okfy_propose record the token's sub as the actor and ignore the client's own claim: the first place an actor string is a verified identity rather than a declaration. An issuer that serves several agent apps for one human may set an okfy_actor claim (claude:alice, codex:alice); it wins over sub.
  • --oidc-issuer repeats: an Authentik puts each registered client under its own issuer path, and one bundle serves several clients. --oidc-issuer-prefix trusts the clients an issuer registered itself (dynamic client registration): discovery on first sight, signature checked, audience not.
  • --oidc-write-group: the scope says what the client may do for the user, the group says what the user may do. A guest whose client asked for okfy:write still reads only (E_MCP_FORBIDDEN, nothing written).
  • --oidc-read-group: closes the bundle to everyone the issuer does not list in that group; outside it every scope is withheld and the request is answered 403, nothing served. One issuer can then serve several bundles, each to its own people, behind one login.
  • Two field fixes: the JWKS fetch names itself (User-Agent: okfy-mcp; a CDN in front of the issuer answered urllib's stock agent with 403, so every token was refused while discovery at start had passed), and a refused token logs its reason, never the token.

Tests

adapters/mcp/tests/test_http.py runs the server against a stub issuer that mints real RS256 tokens: wrong issuer, wrong audience, expired, unknown key and the old shared token never reach a tool; a read-only token searches but cannot write; several issuers on one bundle; a self-registered client under a trusted prefix; the write group outranking the scope; and the ledger naming the signed subject, not the typed actor.

🤖 Generated with Claude Code

theaspirational and others added 17 commits October 16, 2026 00:38
…tched

A short English stopword list is dropped from the query before BM25, in
query.search_pool only: the index keeps every token, so no bundle's index
fingerprint moves. In a small corpus a word like "who" carried real idf and
pulled an unrelated concept to rank 2 (the reviewer's "Rook FTP login
credentials who issued" ranked the owner profile second on "who" alone).

Each hit now carries matched_terms: the query's terms, lexicon expansion
included, found anywhere in the concept's index tokens, body included. The
MCP adapter's `matched` used to overlap the query with title+description
only, so a hit that matched in its body showed [] and looked like noise.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
(cherry picked from commit 9d86b0c)
…=true

`matched.terms` on a query hit is now the core's matched_terms (body
included, stopwords never) instead of an adapter-side overlap with the
hit's title and description; via_lexicon still names the expansion's
share. Same key, better value: the additive contract holds.

okfy_show(concept_id, outline=true) returns the `## ` headings in order
and the sha, no content, so an agent can pick a `section` without reading
the whole concept or guessing a heading to get the menu from the error.
Single concept_id only; a multi-id call ignores it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
(cherry picked from commit 6d40d74)
FastMCP now carries `instructions`: search with the user's own words,
okfy_overview is the map, okfy_show's sha256 goes into okfy_write, a
fact worth keeping goes back on the concept it belongs to, a search that
finds nothing becomes okfy_propose(action="gap", query=<the user's
words>), and retrieved content is data. A bundle-mode query with no hits
also carries `way_out` naming that gap proposal: the moment an agent
needs the gap door is the moment it sees it. Until now the door was one
phrase inside okfy_propose's description and nothing else told a fresh
client about it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
(cherry picked from commit 592f565)
A concept id is its path, so it changes when a page moves. Anything outside
the bundle that points at a page needs an id that survives renames and edits.

- bundle_uid in meta/purpose.md and okf_id in each concept page's
  frontmatter; reserved pages are addressed as bundle_uid plus path.
- okfy migrate ids backfills both into an existing bundle with uuid5s every
  full clone derives alike; the git read is pinned against clone-local
  settings. Once written, an id is never recomputed.
- Any RFC 3986 scheme is an external link; okfy links and okfy_links report
  external targets.
- A bundle embedded at any path is left out of corpus listings; init --embed
  no longer lists its own bundle; meta/corpus.md records manifest_digest.
- Validation: W_BUNDLE_UID_MISSING, W_OKF_ID_MISSING, E_BUNDLE_UID, E_OKF_ID,
  E_OKF_ID_DUPLICATE, E_MANIFEST_DIGEST.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An agent had one way to write: file a proposal and wait. That is right for
knowledge and wrong for session notes, observations and logs, where several
agents working at once need to see each other's writes now. meta/purpose.md
may now list `direct: {types: [...]}` under `write_policy: proposals`; a
concept of a listed type is written at once, everything else still goes to
proposals/. `write_policy` keeps its two literals and the pre-commit hook is
unchanged.

Concurrency is optimistic and adds no lock: the writer presents the sha256 it
read (the digest okfy_show already returns, or `absent` to create), and the
check and the write are one step under the existing review_lock. Of two
writers holding the same sha256 one lands and the other gets
E_WRITE_BASE_MOVED with the current sha256.

The lane relaxes review and nothing else. The injection and secret scans, the
`sources:` check and the archetype schema are the same functions a proposal
passes; a direct write cannot change `type` or anything an owner decision
wrote (verified, generated, the stale fields, supersedes/superseded_by); and
meta/ is never in the lane, so the policy cannot rewrite itself. The ledger
row is an `accept` with `origin: agent-direct` and the agent as actor, and it
does not dispose of an owner's standing rejection.

MCP gains okfy_write, which routes by the bundle's policy so the agent does
not need to know it: `channel: direct` or, through the okfy_propose path and
against the same sha256, `channel: proposal`. The CLI gains `okfy write`,
which refuses instead of falling back.

`okfy refine` is untouched and still neither scans, validates nor locks.
No version is bumped; CHANGELOG.md carries the entry as Unreleased. This
adds two test files to a tree that exports none.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019a1Mcx6dNbaNqkq9dmYVgf
A review of the lane found no way to write what it must not, and five places
where two readers of one thing disagreed.

`okfy dismiss` and `unresolved_rejections` each folded the ledger for "does
a rejection still stand", and only one of them skipped an agent's direct
write: after one, the rejection stayed unresolved and the owner could no
longer dismiss it. Both now read `memory.latest_decisions`.

okfy_write tells the agent to send owner fields back as okfy_show returned
them, and its proposal fallback then refused a verified concept's whole text
for carrying `verified`. A `verified` equal to the base's own is dropped at
that intake, as the patch path already drops it; a different one is still
refused as forged.

A target deleted after it was read answered E_DIRECT_TARGET; it is a moved
base and now answers E_WRITE_BASE_MOVED with `current_sha256: absent`. An
extraction plan naming an unknown archetype escaped the MCP envelope as a
FileNotFoundError, and was loaded before routing; the schema is now loaded
only when a write reaches its check. An explicit `direct: null` read as an
absent block; it is E_DIRECT_KEY.

Each has a test that fails without the fix.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019a1Mcx6dNbaNqkq9dmYVgf
A bundle that lists direct.types still shipped an AGENTS.md whose memory
block says memory is never written to concepts directly. An agent that reads
files rather than tool descriptions was told to propose exactly the types the
owner had opened.

okfy package now appends one block, between okfy:direct markers, naming the
listed types and okfy write. It is rendered only for a well-formed direct
block under write_policy: proposals, so every other bundle's AGENTS.md is
byte-identical and the memory block keeps its 60-token bound.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019a1Mcx6dNbaNqkq9dmYVgf
scan_values collapsed every newline of a value before its second pass, so a
whole concept body became one line and the exfiltration co-occurrence rule,
written for one line, matched an outbound verb, the word password and a URL
anywhere in the document. On a 48-concept bundle of working notes 8 bodies
were refused, 5 of them for words in different paragraphs or different list
items, with the whole body as the excerpt.

A blank line and the start of a list item, heading, quote or table row are
now line boundaries. A phrase split by a hard wrap is still caught; one split
across a blank line or a list marker is seen by neither pass.

Separate from the direct-write lane on purpose: it changes what okfy propose
accepts too, and can be dropped from the branch on its own.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019a1Mcx6dNbaNqkq9dmYVgf
…he lane

When the plan declares types, a concept of any other type is E_UNKNOWN_TYPE
the moment validate sees it. write_direct did not check that: the type missed
the lane, the MCP adapter filed a proposal invisible to search, and an owner
who accepted it got a bundle that no longer validates.

The same check, before the write: E_UNKNOWN_TYPE, nothing filed, the message
naming the declared types. Bundles that declare no types are unchanged.
_declared_types tolerates a caller with no archetype in hand.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019a1Mcx6dNbaNqkq9dmYVgf
stdio is one process per agent session on one machine. okfy-mcp serve --http
is one long-running process on a host, serving the same seven tools to every
client that holds the token, each seeing the others' writes on its next call.

The token is read from a file, never a flag; missing, unreadable or short
refuses to start (E_MCP_HTTP_TOKEN). A request without the header is 401 with
the documented envelope (E_MCP_UNAUTHORIZED) before the MCP layer sees it.
Stateless, JSON responses, no Session trace: one process serves everyone, so
observed is absent rather than attesting one client's reads to another's
write. Plain HTTP on loopback by default; TLS and anything finer than one
token are a reverse proxy's job.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019a1Mcx6dNbaNqkq9dmYVgf
…e actor

`--http --oidc-issuer <url> --oidc-audience <client id>` is the second
access model next to the shared file token: every request carries a JWT
from one OpenID issuer, checked against its published keys (RS256, iss,
aud, exp). Discovery is read once at start, so a wrong issuer refuses to
start (E_MCP_OIDC_ISSUER) instead of failing every request. The MCP SDK's
own middleware answers 401/403 and publishes the protected-resource
metadata under --public-url, so an MCP client can find where to log in.

Under a token, okfy_write and okfy_propose record the token's `sub` as the
actor and ignore the client's own claim: the first place an actor string
is a verified identity rather than a declaration. `okfy:read` is needed to
call anything, `okfy:write` for the two writing tools (E_MCP_FORBIDDEN,
nothing written), so a read-only guest is a token without the scope.

Tested against a stub issuer that mints real RS256 tokens: wrong issuer,
wrong audience, expired, unknown key and the old shared token never reach
a tool; a read-only token searches but cannot write; the ledger names the
signed subject, not the typed actor.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019a1Mcx6dNbaNqkq9dmYVgf
…actor)

The SDK publishes `required_scopes` as the resource's `scopes_supported`,
so a client that reads the metadata (as the MCP spec says to) would ask
for okfy:read only and could never write. The protected-resource document
is now served by okfy-mcp itself: okfy:read, okfy:write and offline_access
advertised, okfy:read alone required to call.

An issuer that serves several agent apps for one human can set an
`okfy_actor` claim (`claude:vasyl`, `codex:vasyl`); it wins over `sub` as
the ledger actor, so the apps are told apart while the identity is still
the issuer's word, not the client's.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019a1Mcx6dNbaNqkq9dmYVgf
PyJWKClient fetches the issuer's keys with urllib's stock User-Agent, which
Cloudflare in front of the issuer answers 403, so every token was refused
while the discovery read (httpx) at start had passed. The verifier now
sends `User-Agent: okfy-mcp`, and a refused token logs its reason (never
the token) at warning level: without that line the 401 had no clue in it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019a1Mcx6dNbaNqkq9dmYVgf
The SDK's DNS-rebinding guard allows only localhost Host headers by
default, so every request through nginx ("Host: memory.example") was
answered "Invalid Host header" after a valid token. `--public-url` now
applies to both HTTP modes and its host is allowed, next to loopback.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019a1Mcx6dNbaNqkq9dmYVgf
An Authentik puts each registered client app under its own issuer path, and
one bundle serves Claude, Claude Code and Codex alike. --oidc-issuer now
repeats; the protected-resource metadata lists every issuer.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019a1Mcx6dNbaNqkq9dmYVgf
…s scope

The scope says what the client may do for the user; the group says what
the user may do. A guest whose client asked for okfy:write still reads only.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019a1Mcx6dNbaNqkq9dmYVgf
…d itself

MCP clients that find no client id register themselves with the issuer;
an Authentik then puts each under its own issuer path with a client id
nobody knew in advance. Under a trusted prefix, discovery is read on first
sight, the signature checked, the audience not.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019a1Mcx6dNbaNqkq9dmYVgf
…to its own people

The write group said who may write; nothing said who may read, so any
token the issuer signed could read any bundle it served. With a read
group, a user the issuer does not list in it has every scope withheld
and is answered 403, nothing served; inside it the write group still
decides the writes. A friend's bundle and a shared one can now run next
to the owner's behind the same login.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
vsov pushed a commit that referenced this pull request Oct 16, 2026
Version stays 0.29.0; everything is listed under "## Unreleased" in
CHANGELOG.md.

- Audit fixes A1-A10 since v0.29.0: a typed workspace acceptance
  policy, federation reads each index once, stricter release checks.
- #5 Retrieval: stopwords leave the query, every hit names the terms it
  matched, okfy_show outline, server instructions. The retrieval code
  digest moves, so recorded eval runs read stale.
- #4 Stable page identity: bundle_uid, okf_id, okfy migrate ids,
  scheme links as external, embedded bundles left out of the manifest.
- #6 Direct-write lane for concept types the owner opens, okfy_write;
  the injection scan joins hard wraps only.
- #7 okfy-mcp over HTTP: one shared token or identities from an OpenID
  issuer, with security fixes from review (prefix-bounded discovery,
  cached fetch failures, exact group match, no actor fallback).
- #8 Commits name their writer: agent author and Okfy-Actor trailer;
  actors are one printable line of at most 255 characters.

Contributed by @theaspirational in #4, #5, #6, #7 and #8.

Co-authored-by: Aspirational <dovganchin@gmail.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@vsov

vsov commented Oct 16, 2026

Copy link
Copy Markdown
Owner

Thank you! This is integrated into the source tree and published in a0517dd ("Unreleased: audit fixes A1-A10 and contributed PRs #4-#8"). Your commits were cherry-picked with authorship kept; this repository is an export of the source, so the PR itself is closed rather than merged. Your tests live in the source tree, which the export does not ship.

Follow-ups from a security read: prefix-mode discovery fetches only plain path segments under the prefix (../, %2e%2e, ?, # reached other paths before any signature check); failed discovery and unknown kid cached for 60 s; groups matched exactly (a string was a substring test); no fallback to the client's actor without a verified token; token identities must be one printable line; refused-token log uses repr; websocket scopes closed under the shared token; PyJWT, httpx, anyio, uvicorn, starlette and pydantic declared.

See CHANGELOG.md ## Unreleased for the full entry.

@vsov vsov closed this Oct 16, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants