Repository navigation
okfy-mcp over HTTP: one shared token, or identities from an OpenID issuer - #7
theaspirational wants to merge 18 commits into
Conversation
…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>
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>
|
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 ( See CHANGELOG.md |
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 --httpis 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
E_MCP_HTTP_TOKEN). A request without the header is 401 with the documented envelope (E_MCP_UNAUTHORIZED) before the MCP layer sees it.Sessiontrace: one process serves everyone, soobservedis absent rather than attesting one client's reads to another's write.--public-urlnames 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_accessadvertised,okfy:readalone required), so an MCP client can find where to log in and ask for the right scopes.okfy_writeandokfy_proposerecord the token'ssubas 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 anokfy_actorclaim (claude:alice,codex:alice); it wins oversub.--oidc-issuerrepeats: an Authentik puts each registered client under its own issuer path, and one bundle serves several clients.--oidc-issuer-prefixtrusts 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 forokfy:writestill 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.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.pyruns 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